Files
Daniel Hiltgen 16739dee60 server: align generate with native chat templates (#16878)
* server: align generate with native chat templates

/api/generate rebuilt chat-like prompts through the Go template path even when the model selected its native GGUF Jinja chat template, so the same model rendered differently between generate and chat.

Route chat-like generate requests through the shared native chat preparation path, keep deprecated context and image handling working there, and keep explicit OLLAMA_GO_TEMPLATE overrides intact.

Fixes #16792

* review comments

Fall back to "{{ .Prompt }}" when lacking templates
2026-06-24 13:43:56 -07:00

261 lines
7.6 KiB
Go

//go:build integration && generate
package integration
import (
"context"
"fmt"
"os"
"strings"
"testing"
"time"
"github.com/ollama/ollama/api"
)
func TestGenerateNativeJinjaDebugRender(t *testing.T) {
if os.Getenv("OLLAMA_TEST_EXISTING") != "" {
t.Skip("requires a test-managed server started with OLLAMA_GO_TEMPLATE=0")
}
t.Setenv("OLLAMA_GO_TEMPLATE", "0")
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()
client, _, cleanup := InitServerConnection(ctx, t)
defer cleanup()
pullOrSkip(ctx, t, client, smol)
modelName := createGenerateJinjaModel(ctx, t, client, smol)
defer func() {
if err := client.Delete(context.Background(), &api.DeleteRequest{Model: modelName}); err != nil {
t.Logf("failed to delete %s: %v", modelName, err)
}
}()
t.Run("generate matches chat", func(t *testing.T) {
prompt := "Reply with one short sentence about blue skies."
generateRender := debugGenerateRender(ctx, t, client, api.GenerateRequest{
Model: modelName,
Prompt: prompt,
})
chatRender := debugChatRender(ctx, t, client, api.ChatRequest{
Model: modelName,
Messages: []api.Message{
{Role: "user", Content: prompt},
},
})
if generateRender == "" {
t.Fatal("generate debug render was empty")
}
if strings.Contains(generateRender, "GO_TEMPLATE_SENTINEL") {
t.Fatalf("generate used Go TEMPLATE instead of native chat template: %q", generateRender)
}
if generateRender != chatRender {
t.Fatalf("generate render does not match chat render\ngenerate: %q\nchat: %q", generateRender, chatRender)
}
})
t.Run("context prepends prior tokens", func(t *testing.T) {
first := generateOnce(ctx, t, client, api.GenerateRequest{
Model: modelName,
Prompt: "Say hello.",
Options: map[string]any{
"num_predict": 1,
"temperature": 0,
},
})
if len(first.Context) == 0 {
t.Fatal("generate response did not include context")
}
next := api.GenerateRequest{
Model: modelName,
Prompt: "Say goodbye.",
}
withoutContext := debugGenerateRender(ctx, t, client, next)
next.Context = first.Context
withContext := debugGenerateRender(ctx, t, client, next)
if withContext == withoutContext {
t.Fatal("context debug render did not change the prompt")
}
if !strings.HasSuffix(withContext, withoutContext) {
t.Fatalf("context render should preserve current native template as suffix\nwith context: %q\nwithout: %q", withContext, withoutContext)
}
})
t.Run("images use completion media markers", func(t *testing.T) {
resp := debugGenerate(ctx, t, client, api.GenerateRequest{
Model: modelName,
Prompt: "compare [img] and [img]",
Images: []api.ImageData{
[]byte("first-test-image"),
[]byte("second-test-image"),
},
})
if resp.DebugInfo == nil {
t.Fatal("missing debug info")
}
if resp.DebugInfo.ImageCount != 2 {
t.Fatalf("image count = %d, want 2", resp.DebugInfo.ImageCount)
}
rendered := resp.DebugInfo.RenderedTemplate
if !strings.Contains(rendered, "[img-0]") || !strings.Contains(rendered, "[img-1]") {
t.Fatalf("rendered template missing image markers: %q", rendered)
}
})
}
func TestGenerateNativeJinjaImages(t *testing.T) {
if os.Getenv("OLLAMA_TEST_EXISTING") != "" {
t.Skip("requires a test-managed server started with OLLAMA_GO_TEMPLATE=0")
}
t.Setenv("OLLAMA_GO_TEMPLATE", "0")
skipUnderMinVRAM(t, 6)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()
client, _, cleanup := InitServerConnection(ctx, t)
defer cleanup()
_, _, ollamaHome := decodeTestImages(t)
t.Run("actual image processing", func(t *testing.T) {
for _, baseModel := range testModels([]string{"gemma3:4b"}) {
t.Run(baseModel, func(t *testing.T) {
setupVisionModel(ctx, t, client, baseModel)
modelName := createGenerateJinjaModel(ctx, t, client, baseModel)
defer func() {
if err := client.Delete(context.Background(), &api.DeleteRequest{Model: modelName}); err != nil {
t.Logf("failed to delete %s: %v", modelName, err)
}
}()
req := api.GenerateRequest{
Model: modelName,
Prompt: "Describe what you see in this image briefly.",
Images: []api.ImageData{ollamaHome},
Options: map[string]any{
"seed": 42,
"temperature": 0.0,
},
KeepAlive: &api.Duration{Duration: 10 * time.Second},
}
resp := debugGenerate(ctx, t, client, req)
if resp.DebugInfo == nil {
t.Fatal("missing debug info")
}
if resp.DebugInfo.ImageCount != 1 {
t.Fatalf("image count = %d, want 1", resp.DebugInfo.ImageCount)
}
rendered := resp.DebugInfo.RenderedTemplate
if strings.Contains(rendered, "GO_TEMPLATE_SENTINEL") {
t.Fatalf("generate with images used Go TEMPLATE instead of native chat template: %q", rendered)
}
if !strings.Contains(rendered, "[img-0]") {
t.Fatalf("rendered template missing image marker: %q", rendered)
}
DoGenerate(ctx, t, client, req, []string{
"llama", "animal", "build", "model", "open", "cartoon", "character",
}, 120*time.Second, 30*time.Second)
})
}
})
}
func createGenerateJinjaModel(ctx context.Context, t *testing.T, client *api.Client, baseModel string) string {
t.Helper()
modelName := fmt.Sprintf("test-generate-jinja-%d", time.Now().UnixNano())
stream := false
err := client.Create(ctx, &api.CreateRequest{
Model: modelName,
From: baseModel,
Stream: &stream,
Template: `GO_TEMPLATE_SENTINEL {{ if .System }}{{ .System }} {{ end }}{{ if .Prompt }}{{ .Prompt }}{{ end }}{{ range .Messages }}{{ .Content }}{{ end }}`,
}, func(api.ProgressResponse) error {
return nil
})
if err != nil {
t.Fatalf("create %s from %s: %v", modelName, baseModel, err)
}
return modelName
}
func debugGenerateRender(ctx context.Context, t *testing.T, client *api.Client, req api.GenerateRequest) string {
t.Helper()
resp := debugGenerate(ctx, t, client, req)
if resp.DebugInfo == nil {
t.Fatal("missing debug info")
}
return resp.DebugInfo.RenderedTemplate
}
func debugGenerate(ctx context.Context, t *testing.T, client *api.Client, req api.GenerateRequest) api.GenerateResponse {
t.Helper()
stream := false
req.Stream = &stream
req.DebugRenderOnly = true
var final api.GenerateResponse
if err := client.Generate(ctx, &req, func(resp api.GenerateResponse) error {
final = resp
return nil
}); err != nil {
if strings.Contains(err.Error(), "requires more system memory") {
t.Skipf("model is too large for this test system: %v", err)
}
t.Fatalf("generate debug render failed: %v", err)
}
return final
}
func debugChatRender(ctx context.Context, t *testing.T, client *api.Client, req api.ChatRequest) string {
t.Helper()
stream := false
req.Stream = &stream
req.DebugRenderOnly = true
var final api.ChatResponse
if err := client.Chat(ctx, &req, func(resp api.ChatResponse) error {
final = resp
return nil
}); err != nil {
if strings.Contains(err.Error(), "requires more system memory") {
t.Skipf("model is too large for this test system: %v", err)
}
t.Fatalf("chat debug render failed: %v", err)
}
if final.DebugInfo == nil {
t.Fatal("missing debug info")
}
return final.DebugInfo.RenderedTemplate
}
func generateOnce(ctx context.Context, t *testing.T, client *api.Client, req api.GenerateRequest) api.GenerateResponse {
t.Helper()
stream := false
req.Stream = &stream
var final api.GenerateResponse
if err := client.Generate(ctx, &req, func(resp api.GenerateResponse) error {
final = resp
return nil
}); err != nil {
if strings.Contains(err.Error(), "requires more system memory") {
t.Skipf("model is too large for this test system: %v", err)
}
t.Fatalf("generate failed: %v", err)
}
return final
}