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:
315
model/parsers/cohere.go
Normal file
315
model/parsers/cohere.go
Normal 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
|
||||
}
|
||||
268
model/parsers/cohere_test.go
Normal file
268
model/parsers/cohere_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
@@ -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
233
model/renderers/cohere.go
Normal 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
|
||||
}
|
||||
189
model/renderers/cohere_test.go
Normal file
189
model/renderers/cohere_test.go
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user