server: apply format constraint for all thinking parsers when think=false (#15901)

This commit is contained in:
Arkadeep Dutta
2026-07-07 13:54:50 -05:00
committed by GitHub
parent f3d69a3dee
commit 892e7f6be6
2 changed files with 115 additions and 114 deletions
+1 -3
View File
@@ -2766,9 +2766,7 @@ func (s *Server) ChatHandler(c *gin.Context) {
// current approach uses the transition from parsed thinking content to // current approach uses the transition from parsed thinking content to
// parsed non-thinking content as the signal to turn constraining on // parsed non-thinking content as the signal to turn constraining on
// TODO(parthsareen): temporary fix for https://github.com/ollama/ollama/issues/15260. forceImmediate := builtinParser != nil && builtinParser.HasThinkingSupport() && req.Think != nil && !req.Think.Bool()
// To revisit for other models and have a consistent pattern across models through parsers.
forceImmediate := m.Config.Parser == "gemma4" && req.Think != nil && !req.Think.Bool()
if req.Format != nil && structuredOutputsState == structuredOutputsState_None && !forceImmediate && ((builtinParser != nil || thinkingState != nil) && slices.Contains(m.Capabilities(), model.CapabilityThinking)) { if req.Format != nil && structuredOutputsState == structuredOutputsState_None && !forceImmediate && ((builtinParser != nil || thinkingState != nil) && slices.Contains(m.Capabilities(), model.CapabilityThinking)) {
currentFormat = nil currentFormat = nil
} }
+114 -111
View File
@@ -2811,128 +2811,131 @@ func TestChatWithPromptEndingInThinkTag(t *testing.T) {
} }
// TestChatFormatWithThinkFalse verifies that when a model uses a builtin // TestChatFormatWithThinkFalse verifies that when a model uses a builtin
// parser that supports thinking (e.g. gemma4) and the request explicitly // parser that supports thinking and the request explicitly disables thinking
// disables thinking (think=false), the format constraint is passed to the // (think=false), the format constraint is passed to the first and only
// first and only completion call. Previously, format was deferred for all // completion call. Previously, format was deferred for all thinking-capable
// thinking-capable parsers and only re-applied after an end-of-thinking // parsers and only re-applied after an end-of-thinking transition -- a
// transition — a transition that never fires when thinking is off. See // transition that never fires when thinking is off. See
// https://github.com/ollama/ollama/issues/15260. // https://github.com/ollama/ollama/issues/15260 and
// https://github.com/ollama/ollama/issues/14645.
func TestChatFormatWithThinkFalse(t *testing.T) { func TestChatFormatWithThinkFalse(t *testing.T) {
gin.SetMode(gin.TestMode) gin.SetMode(gin.TestMode)
mock := &mockRunner{ for _, parserName := range []string{"gemma4", "qwen3.5", "qwen3-thinking"} {
CompletionResponse: llm.CompletionResponse{ t.Run(parserName, func(t *testing.T) {
Done: true, mock := &mockRunner{
DoneReason: llm.DoneReasonStop, CompletionResponse: llm.CompletionResponse{
PromptEvalCount: 1, Done: true,
PromptEvalDuration: 1, DoneReason: llm.DoneReasonStop,
EvalCount: 1, PromptEvalCount: 1,
EvalDuration: 1, PromptEvalDuration: 1,
}, EvalCount: 1,
} EvalDuration: 1,
},
}
s := &Server{ s := &Server{
sched: &Scheduler{ sched: &Scheduler{
pendingReqCh: make(chan *LlmRequest, 1), pendingReqCh: make(chan *LlmRequest, 1),
finishedReqCh: make(chan *LlmRequest, 1), finishedReqCh: make(chan *LlmRequest, 1),
expiredCh: make(chan *runnerRef, 1), expiredCh: make(chan *runnerRef, 1),
unloadedCh: make(chan any, 1), unloadedCh: make(chan any, 1),
loaded: make(map[string]*runnerRef), loaded: make(map[string]*runnerRef),
newServerFn: newMockServer(mock), newServerFn: newMockServer(mock),
getGpuFn: getGpuFn, getGpuFn: getGpuFn,
getSystemInfoFn: getSystemInfoFn, getSystemInfoFn: getSystemInfoFn,
waitForRecovery: 250 * time.Millisecond, waitForRecovery: 250 * time.Millisecond,
loadFn: func(req *LlmRequest, _ ml.SystemInfo, _ []ml.DeviceInfo, _ bool) bool { loadFn: func(req *LlmRequest, _ ml.SystemInfo, _ []ml.DeviceInfo, _ bool) bool {
time.Sleep(time.Millisecond) time.Sleep(time.Millisecond)
req.successCh <- &runnerRef{llama: mock} req.successCh <- &runnerRef{llama: mock}
return false return false
}, },
}, },
} }
go s.sched.Run(t.Context()) go s.sched.Run(t.Context())
_, digest := createBinFile(t, ggml.KV{ _, digest := createBinFile(t, ggml.KV{
"general.architecture": "llama", "general.architecture": "llama",
"llama.block_count": uint32(1), "llama.block_count": uint32(1),
"llama.context_length": uint32(8192), "llama.context_length": uint32(8192),
"llama.embedding_length": uint32(4096), "llama.embedding_length": uint32(4096),
"llama.attention.head_count": uint32(32), "llama.attention.head_count": uint32(32),
"llama.attention.head_count_kv": uint32(8), "llama.attention.head_count_kv": uint32(8),
"tokenizer.ggml.tokens": []string{""}, "tokenizer.ggml.tokens": []string{""},
"tokenizer.ggml.scores": []float32{0}, "tokenizer.ggml.scores": []float32{0},
"tokenizer.ggml.token_type": []int32{0}, "tokenizer.ggml.token_type": []int32{0},
}, []*ggml.Tensor{ }, []*ggml.Tensor{
{Name: "token_embd.weight", Shape: []uint64{1}, WriterTo: bytes.NewReader(make([]byte, 4))}, {Name: "token_embd.weight", Shape: []uint64{1}, WriterTo: bytes.NewReader(make([]byte, 4))},
{Name: "blk.0.attn_norm.weight", Shape: []uint64{1}, WriterTo: bytes.NewReader(make([]byte, 4))}, {Name: "blk.0.attn_norm.weight", Shape: []uint64{1}, WriterTo: bytes.NewReader(make([]byte, 4))},
{Name: "blk.0.ffn_down.weight", Shape: []uint64{1}, WriterTo: bytes.NewReader(make([]byte, 4))}, {Name: "blk.0.ffn_down.weight", Shape: []uint64{1}, WriterTo: bytes.NewReader(make([]byte, 4))},
{Name: "blk.0.ffn_gate.weight", Shape: []uint64{1}, WriterTo: bytes.NewReader(make([]byte, 4))}, {Name: "blk.0.ffn_gate.weight", Shape: []uint64{1}, WriterTo: bytes.NewReader(make([]byte, 4))},
{Name: "blk.0.ffn_up.weight", Shape: []uint64{1}, WriterTo: bytes.NewReader(make([]byte, 4))}, {Name: "blk.0.ffn_up.weight", Shape: []uint64{1}, WriterTo: bytes.NewReader(make([]byte, 4))},
{Name: "blk.0.ffn_norm.weight", Shape: []uint64{1}, WriterTo: bytes.NewReader(make([]byte, 4))}, {Name: "blk.0.ffn_norm.weight", Shape: []uint64{1}, WriterTo: bytes.NewReader(make([]byte, 4))},
{Name: "blk.0.attn_k.weight", Shape: []uint64{1}, WriterTo: bytes.NewReader(make([]byte, 4))}, {Name: "blk.0.attn_k.weight", Shape: []uint64{1}, WriterTo: bytes.NewReader(make([]byte, 4))},
{Name: "blk.0.attn_output.weight", Shape: []uint64{1}, WriterTo: bytes.NewReader(make([]byte, 4))}, {Name: "blk.0.attn_output.weight", Shape: []uint64{1}, WriterTo: bytes.NewReader(make([]byte, 4))},
{Name: "blk.0.attn_q.weight", Shape: []uint64{1}, WriterTo: bytes.NewReader(make([]byte, 4))}, {Name: "blk.0.attn_q.weight", Shape: []uint64{1}, WriterTo: bytes.NewReader(make([]byte, 4))},
{Name: "blk.0.attn_v.weight", Shape: []uint64{1}, WriterTo: bytes.NewReader(make([]byte, 4))}, {Name: "blk.0.attn_v.weight", Shape: []uint64{1}, WriterTo: bytes.NewReader(make([]byte, 4))},
{Name: "output.weight", Shape: []uint64{1}, WriterTo: bytes.NewReader(make([]byte, 4))}, {Name: "output.weight", Shape: []uint64{1}, WriterTo: bytes.NewReader(make([]byte, 4))},
}) })
// Use the gemma4 builtin parser — it reports HasThinkingSupport=true, which modelName := "test-" + parserName + "-parser"
// adds CapabilityThinking to the model and previously triggered deferral of w := createRequest(t, s.CreateHandler, api.CreateRequest{
// the format even when the user passed think=false. Model: modelName,
w := createRequest(t, s.CreateHandler, api.CreateRequest{ Files: map[string]string{"file.gguf": digest},
Model: "test-gemma4-parser", Parser: parserName,
Files: map[string]string{"file.gguf": digest}, Template: `{{- range .Messages }}{{ .Role }}: {{ .Content }}{{ end }}`,
Parser: "gemma4", Stream: &stream,
Template: `{{- range .Messages }}{{ .Role }}: {{ .Content }}{{ end }}`, })
Stream: &stream, if w.Code != http.StatusOK {
}) t.Fatalf("create: expected status 200, got %d: %s", w.Code, w.Body.String())
if w.Code != http.StatusOK { }
t.Fatalf("create: expected status 200, got %d: %s", w.Code, w.Body.String())
}
format := json.RawMessage(`{"type":"object","properties":{"answer":{"type":"string"}},"required":["answer"]}`) format := json.RawMessage(`{"type":"object","properties":{"answer":{"type":"string"}},"required":["answer"]}`)
var ( var (
requestsMu sync.Mutex requestsMu sync.Mutex
requests []llm.CompletionRequest requests []llm.CompletionRequest
) )
mock.CompletionFn = func(ctx context.Context, r llm.CompletionRequest, fn func(r llm.CompletionResponse)) error { mock.CompletionFn = func(ctx context.Context, r llm.CompletionRequest, fn func(r llm.CompletionResponse)) error {
requestsMu.Lock() requestsMu.Lock()
requests = append(requests, r) requests = append(requests, r)
requestsMu.Unlock() requestsMu.Unlock()
fn(llm.CompletionResponse{ fn(llm.CompletionResponse{
Content: `{"answer":"42"}`, Content: `{"answer":"42"}`,
Done: true, Done: true,
DoneReason: llm.DoneReasonStop, DoneReason: llm.DoneReasonStop,
PromptEvalCount: 1, PromptEvalCount: 1,
PromptEvalDuration: 1, PromptEvalDuration: 1,
EvalCount: 1, EvalCount: 1,
EvalDuration: 1, EvalDuration: 1,
})
return nil
}
streamRequest := false
think := false
w = createRequest(t, s.ChatHandler, api.ChatRequest{
Model: modelName,
Messages: []api.Message{{Role: "user", Content: "Respond in JSON."}},
Think: &api.ThinkValue{Value: think},
Stream: &streamRequest,
Format: format,
})
if w.Code != http.StatusOK {
t.Fatalf("chat: expected status 200, got %d: %s", w.Code, w.Body.String())
}
if len(requests) != 1 {
t.Fatalf("expected a single completion call, got %d", len(requests))
}
if !bytes.Equal([]byte(format), []byte(requests[0].Format)) {
t.Errorf("expected first completion format to match the request format, got %q", string(requests[0].Format))
}
}) })
return nil
}
streamRequest := false
think := false
w = createRequest(t, s.ChatHandler, api.ChatRequest{
Model: "test-gemma4-parser",
Messages: []api.Message{{Role: "user", Content: "Respond in JSON."}},
Think: &api.ThinkValue{Value: think},
Stream: &streamRequest,
Format: format,
})
if w.Code != http.StatusOK {
t.Fatalf("chat: expected status 200, got %d: %s", w.Code, w.Body.String())
}
if len(requests) != 1 {
t.Fatalf("expected a single completion call, got %d", len(requests))
}
if !bytes.Equal([]byte(format), []byte(requests[0].Format)) {
t.Errorf("expected first completion format to match the request format, got %q", string(requests[0].Format))
} }
} }