models: add cohere2_moe (Command A / North) to the MLX engine (#16670)

Implements Cohere2MoeForCausalLM (e.g. CohereLabs/North-Mini-Code-1.0)
This commit is contained in:
Jeffrey Morgan
2026-06-16 23:15:21 -07:00
committed by GitHub
parent 0f047feef5
commit acfb50d9af
11 changed files with 1899 additions and 0 deletions

315
model/parsers/cohere.go Normal file
View File

@@ -0,0 +1,315 @@
package parsers
import (
"encoding/json"
"log/slog"
"strings"
"unicode"
"github.com/ollama/ollama/api"
)
// CohereParser parses output from Cohere North / Command A 2026 models
// (e.g. North-Mini-Code-1.0). The generation prompt ends with
// <|START_THINKING|> (reasoning on) or <|START_THINKING|><|END_THINKING|>
// (reasoning off), so output begins inside the thinking block when reasoning
// is enabled. After thinking, the model emits either
// <|START_TEXT|>content<|END_TEXT|> or an <|START_ACTION|>[...]<|END_ACTION|>
// tool call array, then <|END_OF_TURN_TOKEN|>.
type CohereParser struct {
state cohereParserState
buffer strings.Builder
callIndex int
}
type cohereParserState int
const (
cohereCollectingThinking cohereParserState = iota
cohereAwaitingBlock
cohereCollectingContent
cohereCollectingAction
)
const (
cohereEndThinking = "<|END_THINKING|>"
cohereStartText = "<|START_TEXT|>"
cohereEndText = "<|END_TEXT|>"
cohereStartAction = "<|START_ACTION|>"
cohereEndAction = "<|END_ACTION|>"
cohereEndOfTurn = "<|END_OF_TURN_TOKEN|>"
// Legacy response markers from the older Command A chat template, which
// also ships in these models' tokenizer_config and shows up in sampled
// output; treated as aliases for START_TEXT / END_TEXT.
cohereStartResponse = "<|START_RESPONSE|>"
cohereEndResponse = "<|END_RESPONSE|>"
)
func (p *CohereParser) HasToolSupport() bool {
return true
}
func (p *CohereParser) HasThinkingSupport() bool {
return true
}
func (p *CohereParser) PreservedTokens() []string {
return []string{
"<|START_THINKING|>", cohereEndThinking,
cohereStartText, cohereEndText,
cohereStartAction, cohereEndAction,
cohereStartResponse, cohereEndResponse,
}
}
func (p *CohereParser) Init(tools []api.Tool, lastMessage *api.Message, thinkValue *api.ThinkValue) []api.Tool {
p.buffer.Reset()
p.callIndex = 0
// The template enables reasoning by default; nil means default.
thinkingEnabled := thinkValue == nil || thinkValue.Bool()
assistantPrefill := lastMessage != nil && lastMessage.Role == "assistant" && lastMessage.Content != ""
switch {
case assistantPrefill:
// The prompt left an open <|START_TEXT|> for continuation.
p.state = cohereCollectingContent
case thinkingEnabled:
p.state = cohereCollectingThinking
default:
p.state = cohereAwaitingBlock
}
return tools
}
func (p *CohereParser) Add(s string, done bool) (content string, thinking string, calls []api.ToolCall, err error) {
p.buffer.WriteString(s)
var contentSb, thinkingSb strings.Builder
for {
c, t, tc, more := p.eat(done)
contentSb.WriteString(c)
thinkingSb.WriteString(t)
calls = append(calls, tc...)
if !more {
break
}
}
for i := range calls {
calls[i].Function.Index = p.callIndex
p.callIndex++
}
return contentSb.String(), thinkingSb.String(), calls, nil
}
// eat consumes what it can from the buffer for the current state. It returns
// more=true when a state transition happened and the remaining buffer should
// be reprocessed.
func (p *CohereParser) eat(done bool) (content string, thinking string, calls []api.ToolCall, more bool) {
buf := p.buffer.String()
if buf == "" {
return "", "", nil, false
}
switch p.state {
case cohereCollectingThinking:
if idx := strings.Index(buf, cohereEndThinking); idx != -1 {
thinking = strings.TrimRightFunc(buf[:idx], unicode.IsSpace)
p.resetBuffer(buf[idx+len(cohereEndThinking):])
p.state = cohereAwaitingBlock
return "", thinking, nil, true
}
// Emit all but a possible partial tag / trailing whitespace.
keep := overlap(buf, cohereEndThinking)
emitEnd := len(buf) - keep
emitEnd -= trailingWhitespaceLen(buf[:emitEnd])
if emitEnd > 0 {
thinking = buf[:emitEnd]
p.resetBuffer(buf[emitEnd:])
}
return "", thinking, nil, false
case cohereAwaitingBlock:
// Between blocks: look for the next text or action opener, skipping
// whitespace and the end-of-turn token.
for _, open := range []string{cohereStartText, cohereStartResponse} {
if idx := strings.Index(buf, open); idx != -1 {
p.resetBuffer(buf[idx+len(open):])
p.state = cohereCollectingContent
return "", "", nil, true
}
}
if idx := strings.Index(buf, cohereStartAction); idx != -1 {
p.resetBuffer(buf[idx+len(cohereStartAction):])
p.state = cohereCollectingAction
return "", "", nil, true
}
trimmed := strings.TrimLeftFunc(buf, unicode.IsSpace)
if strings.HasPrefix(trimmed, cohereEndOfTurn) {
p.resetBuffer(trimmed[len(cohereEndOfTurn):])
return "", "", nil, true
}
if trimmed == "" {
if done {
p.buffer.Reset()
}
return "", "", nil, false
}
// Wait only while the buffer could still grow into one of the
// expected tags. Anything else — bare content or an unrecognized
// tag — streams out as content rather than buffering forever.
if !done && maybePartialTag(trimmed) {
return "", "", nil, false
}
p.buffer.Reset()
p.state = cohereCollectingContent
p.buffer.WriteString(trimmed)
return "", "", nil, true
case cohereCollectingContent:
// END_OF_TURN also closes content for models that skip END_TEXT.
for _, close := range []string{cohereEndText, cohereEndResponse, cohereEndOfTurn} {
if idx := strings.Index(buf, close); idx != -1 {
content = buf[:idx]
p.resetBuffer(buf[idx+len(close):])
p.state = cohereAwaitingBlock
return content, "", nil, true
}
}
keep := max(overlap(buf, cohereEndText), overlap(buf, cohereEndResponse), overlap(buf, cohereEndOfTurn))
emitEnd := len(buf) - keep
if emitEnd > 0 {
content = buf[:emitEnd]
p.resetBuffer(buf[emitEnd:])
}
if done && p.buffer.Len() > 0 {
content += p.buffer.String()
p.buffer.Reset()
}
return content, "", nil, false
case cohereCollectingAction:
if idx := strings.Index(buf, cohereEndAction); idx != -1 {
payload := buf[:idx]
p.resetBuffer(buf[idx+len(cohereEndAction):])
p.state = cohereAwaitingBlock
calls = parseCohereActions(payload)
return "", "", calls, true
}
if done {
// Best effort on truncated output.
calls = parseCohereActions(buf)
p.buffer.Reset()
return "", "", calls, false
}
return "", "", nil, false
}
return "", "", nil, false
}
func (p *CohereParser) resetBuffer(s string) {
p.buffer.Reset()
p.buffer.WriteString(s)
}
// maybePartialTag reports whether s is a proper prefix of one of the tags the
// awaiting-block state recognizes — the only case worth waiting on for more
// output before treating the buffer as content.
func maybePartialTag(s string) bool {
for _, tag := range []string{cohereStartText, cohereStartResponse, cohereStartAction, cohereEndOfTurn} {
if len(s) < len(tag) && strings.HasPrefix(tag, s) {
return true
}
}
return false
}
type cohereToolCall struct {
ToolCallID string `json:"tool_call_id"`
ToolName string `json:"tool_name"`
Parameters api.ToolCallFunctionArguments `json:"parameters"`
}
func (c cohereToolCall) toolCall() api.ToolCall {
return api.ToolCall{
ID: c.ToolCallID,
Function: api.ToolCallFunction{
Name: c.ToolName,
Arguments: c.Parameters,
},
}
}
// parseCohereActions parses the JSON array inside an action block:
// [{"tool_call_id": "0", "tool_name": ..., "parameters": {...}}, ...]
//
// Sampled output occasionally malforms the JSON (a missing comma between
// calls, an unquoted value). When the array as a whole fails to parse, fall
// back to scanning its balanced top-level objects and parse each
// independently, so one bad call doesn't drop its siblings.
func parseCohereActions(payload string) []api.ToolCall {
payload = strings.TrimSpace(payload)
if payload == "" {
return nil
}
var parsed []cohereToolCall
if err := json.Unmarshal([]byte(payload), &parsed); err == nil {
calls := make([]api.ToolCall, 0, len(parsed))
for _, c := range parsed {
calls = append(calls, c.toolCall())
}
return calls
}
var calls []api.ToolCall
for _, obj := range scanJSONObjects(payload) {
var c cohereToolCall
if err := json.Unmarshal([]byte(obj), &c); err != nil {
if len(obj) > 200 {
obj = obj[:200] + "…"
}
slog.Warn("cohere action parsing failed", "error", err, "action", obj)
continue
}
calls = append(calls, c.toolCall())
}
return calls
}
// scanJSONObjects returns the balanced top-level {...} chunks of s, tracking
// strings and escapes so braces inside values don't split objects.
func scanJSONObjects(s string) []string {
var objects []string
depth, start := 0, -1
inString, escaped := false, false
for i := range len(s) {
switch c := s[i]; {
case escaped:
escaped = false
case c == '\\' && inString:
escaped = true
case c == '"':
inString = !inString
case inString:
case c == '{':
if depth == 0 {
start = i
}
depth++
case c == '}':
if depth > 0 {
depth--
if depth == 0 && start >= 0 {
objects = append(objects, s[start:i+1])
start = -1
}
}
}
}
return objects
}

View File

@@ -0,0 +1,268 @@
package parsers
import (
"testing"
"github.com/ollama/ollama/api"
)
// Model output begins inside <|START_THINKING|> when reasoning is on (the
// generation prompt ends with that tag).
func cohereAddAll(t *testing.T, p *CohereParser, chunks []string) (content, thinking string, calls []api.ToolCall) {
t.Helper()
for i, c := range chunks {
done := i == len(chunks)-1
ct, th, tc, err := p.Add(c, done)
if err != nil {
t.Fatal(err)
}
content += ct
thinking += th
calls = append(calls, tc...)
}
return content, thinking, calls
}
func TestCohereParseThinkingThenText(t *testing.T) {
p := &CohereParser{}
p.Init(nil, nil, nil)
content, thinking, calls := cohereAddAll(t, p, []string{
"Let me think", " about this.<|END_THINKING|>",
"<|START_TEXT|>Hello", " world!<|END_TEXT|>",
})
if thinking != "Let me think about this." {
t.Errorf("thinking = %q", thinking)
}
if content != "Hello world!" {
t.Errorf("content = %q", content)
}
if len(calls) != 0 {
t.Errorf("unexpected calls: %v", calls)
}
}
func TestCohereParseSplitTags(t *testing.T) {
p := &CohereParser{}
p.Init(nil, nil, nil)
// Tags split across chunk boundaries must not leak into output.
content, thinking, _ := cohereAddAll(t, p, []string{
"think<|END_TH", "INKING|><|STAR", "T_TEXT|>ans", "wer<|END_", "TEXT|>",
})
if thinking != "think" {
t.Errorf("thinking = %q", thinking)
}
if content != "answer" {
t.Errorf("content = %q", content)
}
}
func TestCohereParseToolCall(t *testing.T) {
p := &CohereParser{}
p.Init(nil, nil, nil)
content, thinking, calls := cohereAddAll(t, p, []string{
"plan<|END_THINKING|><|START_ACTION|>[\n",
` {"tool_call_id": "0", "tool_name": "get_weather", "parameters": {"city": "Paris"}},`,
` {"tool_call_id": "1", "tool_name": "get_time", "parameters": {}}`,
"\n]<|END_ACTION|>",
})
if thinking != "plan" {
t.Errorf("thinking = %q", thinking)
}
if content != "" {
t.Errorf("content = %q", content)
}
if len(calls) != 2 {
t.Fatalf("calls = %d, want 2", len(calls))
}
if calls[0].Function.Name != "get_weather" || calls[1].Function.Name != "get_time" {
t.Errorf("call names = %q, %q", calls[0].Function.Name, calls[1].Function.Name)
}
if v, ok := calls[0].Function.Arguments.Get("city"); !ok || v != "Paris" {
t.Errorf("call 0 city = %v %v", v, ok)
}
if calls[0].Function.Index != 0 || calls[1].Function.Index != 1 {
t.Errorf("call indices = %d, %d", calls[0].Function.Index, calls[1].Function.Index)
}
}
func TestCohereParseReasoningOff(t *testing.T) {
p := &CohereParser{}
think := &api.ThinkValue{Value: false}
p.Init(nil, nil, think)
content, thinking, _ := cohereAddAll(t, p, []string{
"<|START_TEXT|>direct answer<|END_TEXT|>",
})
if thinking != "" {
t.Errorf("thinking = %q", thinking)
}
if content != "direct answer" {
t.Errorf("content = %q", content)
}
}
func TestCohereParseBareContent(t *testing.T) {
// Models occasionally skip the START_TEXT wrapper; treat raw text after
// thinking as content.
p := &CohereParser{}
p.Init(nil, nil, nil)
content, thinking, _ := cohereAddAll(t, p, []string{
"thought<|END_THINKING|>", "Just plain text", " output",
})
if thinking != "thought" {
t.Errorf("thinking = %q", thinking)
}
if content != "Just plain text output" {
t.Errorf("content = %q", content)
}
}
func TestCohereParseEndOfTurnWithoutEndText(t *testing.T) {
p := &CohereParser{}
p.Init(nil, nil, nil)
content, _, _ := cohereAddAll(t, p, []string{
"t<|END_THINKING|><|START_TEXT|>answer<|END_OF_TURN_TOKEN|>",
})
if content != "answer" {
t.Errorf("content = %q", content)
}
}
func TestCohereParsePrefillContinuation(t *testing.T) {
p := &CohereParser{}
last := &api.Message{Role: "assistant", Content: "partial"}
p.Init(nil, last, nil)
content, thinking, _ := cohereAddAll(t, p, []string{" continued<|END_TEXT|>"})
if thinking != "" {
t.Errorf("thinking = %q", thinking)
}
if content != " continued" {
t.Errorf("content = %q", content)
}
}
func TestCohereParserRegistered(t *testing.T) {
p := ParserForName("cohere")
if p == nil {
t.Fatal("cohere parser not registered")
}
if !p.HasToolSupport() || !p.HasThinkingSupport() {
t.Error("cohere parser should support tools and thinking")
}
}
func TestCohereParseMalformedActions(t *testing.T) {
// One malformed call (unquoted value) must not drop its well-formed
// sibling, and a missing comma between calls must not drop either.
p := &CohereParser{}
p.Init(nil, nil, nil)
_, _, calls := cohereAddAll(t, p, []string{
"plan<|END_THINKING|><|START_ACTION|>[\n",
` {"tool_call_id": "0", "tool_name": "set_alarm", "parameters": {"time": 15:30}},`,
` {"tool_call_id": "1", "tool_name": "get_weather", "parameters": {"city": "Oslo"}}`,
"\n]<|END_ACTION|>",
})
if len(calls) != 1 || calls[0].Function.Name != "get_weather" {
t.Fatalf("calls = %v, want the well-formed get_weather call", calls)
}
p = &CohereParser{}
p.Init(nil, nil, nil)
_, _, calls = cohereAddAll(t, p, []string{
`<|END_THINKING|><|START_ACTION|>[`,
`{"tool_call_id": "0", "tool_name": "a", "parameters": {}}`,
`{"tool_call_id": "1", "tool_name": "b", "parameters": {"x": "{not json}"}}`,
`]<|END_ACTION|>`,
})
if len(calls) != 2 || calls[0].Function.Name != "a" || calls[1].Function.Name != "b" {
t.Fatalf("calls = %v, want both calls despite missing comma", calls)
}
if v, ok := calls[1].Function.Arguments.Get("x"); !ok || v != "{not json}" {
t.Fatalf("braces inside string values must not split objects, got %v", v)
}
// Unparseable garbage yields no calls and no panic.
p = &CohereParser{}
p.Init(nil, nil, nil)
_, _, calls = cohereAddAll(t, p, []string{"x<|END_THINKING|><|START_ACTION|>[!!!]<|END_ACTION|>"})
if len(calls) != 0 {
t.Fatalf("calls = %v, want none", calls)
}
}
func TestCohereParseLegacyResponseMarkers(t *testing.T) {
// Models trained on the older Command A template sometimes emit
// <|START_RESPONSE|>/<|END_RESPONSE|> instead of START_TEXT/END_TEXT.
p := &CohereParser{}
p.Init(nil, nil, nil)
content, thinking, _ := cohereAddAll(t, p, []string{
"plan<|END_THINKING|>", "<|START_RESPONSE|>Hello", " there<|END_RESPONSE|>",
})
if thinking != "plan" {
t.Errorf("thinking = %q", thinking)
}
if content != "Hello there" {
t.Errorf("content = %q", content)
}
}
func TestCohereParseStreamsBeforeDone(t *testing.T) {
// Regression test: output after END_THINKING that opens with an
// unrecognized tag must stream as it arrives, not buffer until the
// generation finishes (it previously buffered forever, presenting as a
// hung response).
for _, tc := range []struct {
name string
chunk string
}{
{"legacy response marker", "<|START_RESPONSE|>The answer is 42."},
{"unrecognized tag", "<|TOOL_PLAN|>I should call the tool."},
{"bare content", "Just plain text."},
} {
t.Run(tc.name, func(t *testing.T) {
p := &CohereParser{}
p.Init(nil, nil, nil)
if _, _, _, err := p.Add("x<|END_THINKING|>", false); err != nil {
t.Fatal(err)
}
content, _, _, err := p.Add(tc.chunk, false) // done=false: still streaming
if err != nil {
t.Fatal(err)
}
if content == "" {
t.Fatalf("content did not stream before done for %q", tc.chunk)
}
})
}
}
func TestCohereParseEndOfTurnBetweenBlocks(t *testing.T) {
// A literal end-of-turn marker between blocks is consumed, not shown.
p := &CohereParser{}
p.Init(nil, nil, nil)
content, thinking, _ := cohereAddAll(t, p, []string{
"t<|END_THINKING|><|START_TEXT|>hi<|END_TEXT|><|END_OF_TURN_TOKEN|>",
})
if thinking != "t" || content != "hi" {
t.Errorf("thinking = %q, content = %q", thinking, content)
}
}
func TestCohereParseBareContentBeforeEndOfTurn(t *testing.T) {
// Bare content followed by an end-of-turn marker keeps the content.
p := &CohereParser{}
p.Init(nil, nil, nil)
content, _, _ := cohereAddAll(t, p, []string{
"t<|END_THINKING|>plain answer<|END_OF_TURN_TOKEN|>",
})
if content != "plain answer" {
t.Errorf("content = %q, want %q", content, "plain answer")
}
}

View File

@@ -92,6 +92,8 @@ func ParserForName(name string) Parser {
return &LFM2Parser{hasThinkingSupport: true}
case "laguna":
return &LagunaParser{}
case "cohere":
return &CohereParser{}
default:
return nil
}

233
model/renderers/cohere.go Normal file
View File

@@ -0,0 +1,233 @@
package renderers
import (
"encoding/json"
"strconv"
"strings"
"github.com/ollama/ollama/api"
)
// CohereRenderer renders the Cohere North / Command A 2026 chat template
// (Cohere2 MoE models such as CohereLabs/North-Mini-Code-1.0): a platform
// system turn with an Available Tools section, <|START_TEXT|>-wrapped message
// bodies, <|START_THINKING|> reasoning, <|START_ACTION|> tool calls, and
// <|START_TOOL_RESULT|> tool results.
//
// The chat template's model-specific platform instructions (identity, default
// policies) belong in the model's Modelfile SYSTEM prompt, which arrives here
// as the first system message.
type CohereRenderer struct{}
func (r *CohereRenderer) LeadingBOS() string {
return "<BOS_TOKEN>"
}
// cohereToolJSON renders one tool entry exactly as the template's tojson
// filter does: {"name": ..., "description": ..., "parameters": {...},
// "responses": null} with ", " / ": " separators.
func cohereToolJSON(tool api.Tool) (string, error) {
params, err := marshalWithSpaces(tool.Function.Parameters)
if err != nil {
return "", err
}
name, err := json.Marshal(tool.Function.Name)
if err != nil {
return "", err
}
desc, err := json.Marshal(tool.Function.Description)
if err != nil {
return "", err
}
var sb strings.Builder
sb.WriteString(`{"name": `)
sb.Write(name)
sb.WriteString(`, "description": `)
sb.Write(desc)
sb.WriteString(`, "parameters": `)
sb.WriteString(string(params))
sb.WriteString(`, "responses": null}`)
return sb.String(), nil
}
// writeToolsSection writes the "# Available Tools" block, reproducing the
// template's whitespace for the empty and populated cases.
func writeToolsSection(sb *strings.Builder, tools []api.Tool) error {
sb.WriteString("# Available Tools\n```json\n[\n")
if len(tools) == 0 {
sb.WriteString("\n\n")
} else {
for i, tool := range tools {
entry, err := cohereToolJSON(tool)
if err != nil {
return err
}
sb.WriteString("\n ")
sb.WriteString(entry)
if i < len(tools)-1 {
sb.WriteString(",")
}
sb.WriteString("\n\n")
}
}
sb.WriteString("\n]\n```")
return nil
}
// cohereToolResult is one entry of a <|START_TOOL_RESULT|> array.
func writeToolResult(sb *strings.Builder, callID string, content string) error {
wrapped, err := marshalWithSpaces(map[string]string{"content": content})
if err != nil {
return err
}
sb.WriteString("\n {\n \"tool_call_id\": \"")
sb.WriteString(callID)
sb.WriteString("\",\n \"results\": {\n\n \n \"0\": ")
sb.WriteString(string(wrapped))
sb.WriteString("\n\n },\n \"is_error\": null\n }")
return nil
}
func (r *CohereRenderer) Render(messages []api.Message, tools []api.Tool, think *api.ThinkValue) (string, error) {
var sb strings.Builder
// The template defaults reasoning to true; an explicit think=false
// disables it.
reasoning := think == nil || think.Bool()
// The first system message — the request's system prompt, or the model's
// Modelfile SYSTEM when the request has none — fills the template's
// platform instruction slot.
var system string
rest := messages
if len(messages) > 0 && strings.EqualFold(messages[0].Role, "system") {
system = messages[0].Content
rest = messages[1:]
}
// Platform system turn: system prompt plus the Available Tools section
// (rendered even when no tools are defined).
sb.WriteString("<|START_OF_TURN_TOKEN|><|SYSTEM_TOKEN|><|START_TEXT|>")
if system != "" {
sb.WriteString(system)
sb.WriteString("\n\n\n\n")
}
if err := writeToolsSection(&sb, tools); err != nil {
return "", err
}
sb.WriteString("<|END_TEXT|><|END_OF_TURN_TOKEN|>")
// Tool call ids regenerate as sequential indices across the whole
// conversation (regen_tool_call_ids default); results reference the
// index of their originating call.
callIndex := 0
callIDToIndex := map[string]string{}
nextCallID := func(id string) string {
idx := strconv.Itoa(callIndex)
callIndex++
if id != "" {
if _, seen := callIDToIndex[id]; !seen {
callIDToIndex[id] = idx
}
}
return idx
}
resolveResultID := func(m api.Message) string {
if idx, ok := callIDToIndex[m.ToolCallID]; ok {
return idx
}
// Fall back to call order when ids are absent.
return m.ToolCallID
}
prefill := false
for i := 0; i < len(rest); i++ {
message := rest[i]
switch strings.ToLower(message.Role) {
case "system":
sb.WriteString("<|START_OF_TURN_TOKEN|><|SYSTEM_TOKEN|><|START_TEXT|>")
sb.WriteString(message.Content)
sb.WriteString("<|END_TEXT|><|END_OF_TURN_TOKEN|>")
case "user":
sb.WriteString("<|START_OF_TURN_TOKEN|><|USER_TOKEN|><|START_TEXT|>")
sb.WriteString(message.Content)
sb.WriteString("<|END_TEXT|><|END_OF_TURN_TOKEN|>")
case "assistant", "chatbot":
sb.WriteString("<|START_OF_TURN_TOKEN|><|CHATBOT_TOKEN|>")
if len(message.ToolCalls) > 0 {
// Whitespace before THINKING/ACTION matches the template's
// (untrimmed jinja blocks).
sb.WriteString("\n \n ")
if message.Thinking != "" {
sb.WriteString("<|START_THINKING|>")
sb.WriteString(message.Thinking)
sb.WriteString("<|END_THINKING|>")
}
sb.WriteString("<|START_ACTION|>[")
for j, tc := range message.ToolCalls {
args, err := marshalWithSpaces(tc.Function.Arguments)
if err != nil {
return "", err
}
sb.WriteString("\n\n {\"tool_call_id\": \"")
sb.WriteString(nextCallID(toolCallID(tc)))
sb.WriteString("\", \"tool_name\": \"")
sb.WriteString(tc.Function.Name)
sb.WriteString("\", \"parameters\": ")
sb.WriteString(string(args))
sb.WriteString("}")
if j < len(message.ToolCalls)-1 {
sb.WriteString(",")
}
}
sb.WriteString("\n\n]<|END_ACTION|><|END_OF_TURN_TOKEN|>")
} else {
if message.Thinking != "" {
sb.WriteString("<|START_THINKING|>")
sb.WriteString(message.Thinking)
sb.WriteString("<|END_THINKING|>")
}
sb.WriteString("<|START_TEXT|>")
sb.WriteString(message.Content)
if i == len(rest)-1 {
// Assistant prefill: leave the text open for
// continuation.
prefill = true
} else {
sb.WriteString("<|END_TEXT|><|END_OF_TURN_TOKEN|>")
}
}
case "tool":
// Consecutive tool messages merge into one TOOL_RESULT array.
sb.WriteString("<|START_OF_TURN_TOKEN|><|SYSTEM_TOKEN|><|START_TOOL_RESULT|>[")
if err := writeToolResult(&sb, resolveResultID(message), message.Content); err != nil {
return "", err
}
for i+1 < len(rest) && strings.EqualFold(rest[i+1].Role, "tool") {
i++
sb.WriteString(",")
if err := writeToolResult(&sb, resolveResultID(rest[i]), rest[i].Content); err != nil {
return "", err
}
}
sb.WriteString("\n\n]<|END_TOOL_RESULT|><|END_OF_TURN_TOKEN|>")
}
}
if !prefill {
sb.WriteString("<|START_OF_TURN_TOKEN|><|CHATBOT_TOKEN|>")
if reasoning {
sb.WriteString("<|START_THINKING|>")
} else {
sb.WriteString("<|START_THINKING|><|END_THINKING|>")
}
}
return sb.String(), nil
}
// toolCallID returns the tool call's id when the client supplied one. The
// api.ToolCall ID field may be empty for calls synthesized by ollama.
func toolCallID(tc api.ToolCall) string {
return tc.ID
}

View File

@@ -0,0 +1,189 @@
package renderers
import (
"testing"
"github.com/ollama/ollama/api"
)
// Ground truth in these tests comes from rendering North-Mini-Code-1.0's
// chat_template.jinja with HF jinja semantics (add_generation_prompt=true),
// minus the leading "<BOS>" from {{ bos_token }} (the tokenizer adds BOS as a
// token at encode time).
const cohereSystemTurnNoTools = "<|START_OF_TURN_TOKEN|><|SYSTEM_TOKEN|><|START_TEXT|>" +
"# Available Tools\n```json\n[\n\n\n\n]\n```" +
"<|END_TEXT|><|END_OF_TURN_TOKEN|>"
func TestCohereRenderUserOnly(t *testing.T) {
r := &CohereRenderer{}
got, err := r.Render([]api.Message{{Role: "user", Content: "USERMSG"}}, nil, nil)
if err != nil {
t.Fatal(err)
}
want := cohereSystemTurnNoTools +
"<|START_OF_TURN_TOKEN|><|USER_TOKEN|><|START_TEXT|>USERMSG<|END_TEXT|><|END_OF_TURN_TOKEN|>" +
"<|START_OF_TURN_TOKEN|><|CHATBOT_TOKEN|><|START_THINKING|>"
if got != want {
t.Errorf("render mismatch:\ngot: %q\nwant: %q", got, want)
}
}
func TestCohereRenderSystemHistoryAndThinking(t *testing.T) {
r := &CohereRenderer{}
got, err := r.Render([]api.Message{
{Role: "system", Content: "DEVPREAMBLE"},
{Role: "user", Content: "Q1"},
{Role: "assistant", Content: "A1", Thinking: "THINK1"},
{Role: "user", Content: "Q2"},
}, nil, nil)
if err != nil {
t.Fatal(err)
}
want := "<|START_OF_TURN_TOKEN|><|SYSTEM_TOKEN|><|START_TEXT|>" +
"DEVPREAMBLE\n\n\n\n# Available Tools\n```json\n[\n\n\n\n]\n```" +
"<|END_TEXT|><|END_OF_TURN_TOKEN|>" +
"<|START_OF_TURN_TOKEN|><|USER_TOKEN|><|START_TEXT|>Q1<|END_TEXT|><|END_OF_TURN_TOKEN|>" +
"<|START_OF_TURN_TOKEN|><|CHATBOT_TOKEN|><|START_THINKING|>THINK1<|END_THINKING|><|START_TEXT|>A1<|END_TEXT|><|END_OF_TURN_TOKEN|>" +
"<|START_OF_TURN_TOKEN|><|USER_TOKEN|><|START_TEXT|>Q2<|END_TEXT|><|END_OF_TURN_TOKEN|>" +
"<|START_OF_TURN_TOKEN|><|CHATBOT_TOKEN|><|START_THINKING|>"
if got != want {
t.Errorf("render mismatch:\ngot: %q\nwant: %q", got, want)
}
}
func TestCohereRenderReasoningOff(t *testing.T) {
r := &CohereRenderer{}
think := &api.ThinkValue{Value: false}
got, err := r.Render([]api.Message{{Role: "user", Content: "Q"}}, nil, think)
if err != nil {
t.Fatal(err)
}
wantSuffix := "<|START_OF_TURN_TOKEN|><|CHATBOT_TOKEN|><|START_THINKING|><|END_THINKING|>"
if got[len(got)-len(wantSuffix):] != wantSuffix {
t.Errorf("reasoning-off generation prompt mismatch, got tail %q", got[len(got)-len(wantSuffix):])
}
}
func TestCohereRenderToolFlow(t *testing.T) {
r := &CohereRenderer{}
tools := []api.Tool{{
Type: "function",
Function: api.ToolFunction{
Name: "get_weather",
Description: "Get weather",
Parameters: api.ToolFunctionParameters{
Type: "object",
Required: []string{"city"},
Properties: testPropsOrdered([]orderedProp{
{Key: "city", Value: api.ToolProperty{Type: api.PropertyType{"string"}}},
}),
},
},
}}
args := api.ToolCallFunctionArguments{}
args.Set("city", "Paris")
got, err := r.Render([]api.Message{
{Role: "user", Content: "weather in Paris?"},
{Role: "assistant", Thinking: "I should call the tool", ToolCalls: []api.ToolCall{
{ID: "call_x", Function: api.ToolCallFunction{Name: "get_weather", Arguments: args}},
}},
{Role: "tool", ToolCallID: "call_x", Content: "15C sunny"},
{Role: "user", Content: "thanks"},
}, tools, nil)
if err != nil {
t.Fatal(err)
}
// Tools section (from jinja): one entry, surrounded by the template's
// whitespace. Key order inside "parameters" follows Go's struct order
// (jinja preserves whatever order the client sent; both are valid JSON
// schema).
wantTools := "# Available Tools\n```json\n[\n\n {\"name\": \"get_weather\", \"description\": \"Get weather\", \"parameters\": {\"type\": \"object\", \"required\": [\"city\"], \"properties\": {\"city\": {\"type\": \"string\"}}}, \"responses\": null}\n\n\n]\n```"
if !contains(t, got, wantTools, "tools section") {
return
}
// Assistant tool call turn.
wantAction := "<|START_OF_TURN_TOKEN|><|CHATBOT_TOKEN|>\n \n <|START_THINKING|>I should call the tool<|END_THINKING|><|START_ACTION|>[\n\n {\"tool_call_id\": \"0\", \"tool_name\": \"get_weather\", \"parameters\": {\"city\": \"Paris\"}}\n\n]<|END_ACTION|><|END_OF_TURN_TOKEN|>"
if !contains(t, got, wantAction, "action turn") {
return
}
// Tool result turn.
wantResult := "<|START_OF_TURN_TOKEN|><|SYSTEM_TOKEN|><|START_TOOL_RESULT|>[\n {\n \"tool_call_id\": \"0\",\n \"results\": {\n\n \n \"0\": {\"content\": \"15C sunny\"}\n\n },\n \"is_error\": null\n }\n\n]<|END_TOOL_RESULT|><|END_OF_TURN_TOKEN|>"
contains(t, got, wantResult, "tool result turn")
}
func TestCohereRenderMultiToolCallsAndResults(t *testing.T) {
r := &CohereRenderer{}
emptyArgs := api.ToolCallFunctionArguments{}
xArgs := api.ToolCallFunctionArguments{}
xArgs.Set("x", 1)
got, err := r.Render([]api.Message{
{Role: "user", Content: "go"},
{Role: "assistant", ToolCalls: []api.ToolCall{
{ID: "a", Function: api.ToolCallFunction{Name: "t1", Arguments: emptyArgs}},
{ID: "b", Function: api.ToolCallFunction{Name: "t2", Arguments: xArgs}},
}},
{Role: "tool", ToolCallID: "a", Content: "r1"},
{Role: "tool", ToolCallID: "b", Content: "r2"},
}, nil, nil)
if err != nil {
t.Fatal(err)
}
wantAction := "<|START_ACTION|>[\n\n {\"tool_call_id\": \"0\", \"tool_name\": \"t1\", \"parameters\": {}},\n\n {\"tool_call_id\": \"1\", \"tool_name\": \"t2\", \"parameters\": {\"x\": 1}}\n\n]<|END_ACTION|>"
if !contains(t, got, wantAction, "multi action") {
return
}
// Consecutive tool messages merge into one result block with sequential
// regenerated ids.
wantResults := "<|START_TOOL_RESULT|>[\n {\n \"tool_call_id\": \"0\",\n \"results\": {\n\n \n \"0\": {\"content\": \"r1\"}\n\n },\n \"is_error\": null\n },\n {\n \"tool_call_id\": \"1\",\n \"results\": {\n\n \n \"0\": {\"content\": \"r2\"}\n\n },\n \"is_error\": null\n }\n\n]<|END_TOOL_RESULT|>"
contains(t, got, wantResults, "merged tool results")
}
func TestCohereRenderAssistantPrefill(t *testing.T) {
r := &CohereRenderer{}
got, err := r.Render([]api.Message{
{Role: "user", Content: "Q"},
{Role: "assistant", Content: "partial"},
}, nil, nil)
if err != nil {
t.Fatal(err)
}
wantSuffix := "<|START_OF_TURN_TOKEN|><|CHATBOT_TOKEN|><|START_TEXT|>partial"
if got[len(got)-len(wantSuffix):] != wantSuffix {
t.Errorf("prefill should leave the text open, got tail %q", got[max(0, len(got)-90):])
}
}
func TestCohereRendererRegistered(t *testing.T) {
if rendererForName("cohere") == nil {
t.Fatal("cohere renderer not registered")
}
if got := LeadingBOSForRenderer("cohere"); got != "<BOS_TOKEN>" {
t.Errorf("LeadingBOS = %q, want <BOS_TOKEN>", got)
}
}
func contains(t *testing.T, haystack, needle, what string) bool {
t.Helper()
if idx := indexOf(haystack, needle); idx == -1 {
t.Errorf("missing %s:\nwant substring: %q\nin: %q", what, needle, haystack)
return false
}
return true
}
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
}

View File

@@ -107,6 +107,8 @@ func rendererForName(name string) Renderer {
return &LFM2Renderer{IsThinking: true, useImgTags: RenderImgTags}
case "laguna":
return &LagunaRenderer{}
case "cohere":
return &CohereRenderer{}
default:
return nil
}