ollama/mlxrunner/client_test.go
Jesse Gross 5a0ff3116d server: apply structured outputs in a single pass on thinking models
A format on a thinking model ran two generations: an unconstrained one,
cancelled once the parser reported content, then a re-rendered prompt with
the parsed thinking under the grammar. The restart cost a second prefill,
dropped the chunk that crossed the boundary, needed a harmony prompt hack,
stitched metrics across the two requests, and on MLX could leak a stray
first token into the JSON. The generate endpoint never deferred at all, so
its JSON was forced inside the thinking.

Both handlers now make one completion request that names the strings
ending the response's thinking, from the builtin parser or the generic
thinking parser, and the runner constrains only the content after them in
a single generation. The prompt is evaluated once and metrics pass straight
through. A raw generate prompt names no strings, since nothing says where
its response starts, and its format applies from the first token as
before. A format now applies to whatever follows the thinking, so a tool
call can no longer take the place of formatted content, which was already
the case with thinking off; harmony is the exception, since its tool calls
precede the final message.

The per-token metrics flag both runners carried for the cancelled first
pass has no caller left and goes with the two-pass code and its tests.

Fixes #18441
Fixes #17544
Fixes #14196
Fixes #10929
2026-09-22 11:24:11 -07:00

49 lines
2.1 KiB
Go

package mlxrunner
import (
"encoding/json"
"testing"
"github.com/ollama/ollama/llm"
)
func TestRequestGrammar(t *testing.T) {
schema := `{"type":"object","properties":{"answer":{"type":"string"}}}`
tag := `{"type":"structural_tag","format":{"type":"json_schema","json_schema":` + schema + `}}`
for _, tt := range []struct {
name string
req llm.CompletionRequest
want string
}{
{name: "unset"},
{name: "null", req: llm.CompletionRequest{Format: json.RawMessage(`null`)}},
{name: "empty", req: llm.CompletionRequest{Format: json.RawMessage(`""`)}},
{
name: "json",
req: llm.CompletionRequest{Format: json.RawMessage(`"json"`)},
want: `{"type":"structural_tag","format":{"type":"json_schema","json_schema":{"type":"object"}}}`,
},
{name: "schema", req: llm.CompletionRequest{Format: json.RawMessage(schema)}, want: tag},
{
name: "schema after thinking",
req: llm.CompletionRequest{Format: json.RawMessage(schema), ThinkingClose: []string{"</think>"}},
want: `{"type":"structural_tag","format":{"type":"sequence","elements":[{"type":"any_text","excludes":["</think>"]},` +
`{"type":"optional","content":{"type":"sequence","elements":[{"type":"const_string","value":"</think>"},` +
`{"type":"json_schema","json_schema":` + schema + `}]}}]}}`,
},
{
name: "schema after thinking with two closings",
req: llm.CompletionRequest{Format: json.RawMessage(schema), ThinkingClose: []string{"<|final|>", "<|final|>json"}},
want: `{"type":"structural_tag","format":{"type":"sequence","elements":[{"type":"any_text","excludes":["<|final|>","<|final|>json"]},` +
`{"type":"optional","content":{"type":"sequence","elements":[{"type":"or","elements":[{"type":"const_string","value":"<|final|>"},{"type":"const_string","value":"<|final|>json"}]},` +
`{"type":"json_schema","json_schema":` + schema + `}]}}]}}`,
},
{name: "thinking without a format", req: llm.CompletionRequest{ThinkingClose: []string{"</think>"}}},
} {
t.Run(tt.name, func(t *testing.T) {
if got := string(requestGrammar(tt.req)); got != tt.want {
t.Fatalf("requestGrammar = %s, want %s", got, tt.want)
}
})
}
}