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
This commit is contained in:
Daniel Hiltgen
2026-06-24 13:43:56 -07:00
committed by GitHub
parent d48d790baf
commit 16739dee60
6 changed files with 601 additions and 32 deletions
+260
View File
@@ -0,0 +1,260 @@
//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
}
+21 -2
View File
@@ -272,13 +272,32 @@ func hasMoreCapabilities(candidate, current []model.Capability) bool {
return len(candidate) > len(current)
}
func shouldPreferChatTemplate(chatTemplate string, chatTemplateCaps []model.Capability, goTemplate *template.Template, goTemplateCaps []model.Capability) bool {
if !hasMoreCapabilities(chatTemplateCaps, goTemplateCaps) {
func sameCapabilities(candidate, current []model.Capability) bool {
if len(candidate) != len(current) {
return false
}
for _, c := range candidate {
if !slices.Contains(current, c) {
return false
}
}
return true
}
func shouldPreferChatTemplate(chatTemplate string, chatTemplateCaps []model.Capability, goTemplate *template.Template, goTemplateCaps []model.Capability) bool {
if hasMoreCapabilities(chatTemplateCaps, goTemplateCaps) {
return !goTemplateHasToolRoundTrip(goTemplate) || chatTemplateHasToolRoundTrip(chatTemplate)
}
if !sameCapabilities(chatTemplateCaps, goTemplateCaps) ||
!slices.Contains(chatTemplateCaps, model.CapabilityTools) ||
!slices.Contains(goTemplateCaps, model.CapabilityTools) {
return false
}
return chatTemplateHasToolRoundTrip(chatTemplate) && !goTemplateHasToolRoundTrip(goTemplate)
}
func goTemplateEnvSet() bool {
return envconfig.GoTemplate(true) == envconfig.GoTemplate(false)
}
+31
View File
@@ -132,6 +132,37 @@ func TestGetModelTemplateMetadata(t *testing.T) {
}
})
t.Run("prefers chat template with stronger tool round trip", func(t *testing.T) {
t.Setenv("OLLAMA_MODELS", t.TempDir())
t.Setenv("OLLAMA_GO_TEMPLATE", "")
_, digest := createBinFile(t, ggml.KV{
"general.architecture": "llama",
"tokenizer.chat_template": `{% if tools %}{{ tools }}{% endif %}
{% for message in messages %}
{% if message.tool_calls %}
{% for tool_call in message.tool_calls %}{{ tool_call.function.name }}{% endfor %}
{% endif %}
{% if message.role == 'tool' %}tool_response {{ message.content }}{% endif %}
{% endfor %}`,
}, nil)
writeTestModelManifest(t, "chat-template-tool-round-trip", digest, `{{ if .Tools }}tools{{ end }}
{{ range .Messages }}
{{ range .ToolCalls }}{{ .Function.Name }}{{ end }}
{{ end }}`)
m, err := GetModel("chat-template-tool-round-trip")
if err != nil {
t.Fatal(err)
}
if !m.PreferChatTemplate {
t.Fatal("expected chat template to be preferred")
}
if got := m.CheckCapabilities(model.CapabilityTools); got != nil {
t.Fatalf("expected tools capability, got %v", got)
}
})
t.Run("keeps Go TEMPLATE when chat template has weaker tool support", func(t *testing.T) {
t.Setenv("OLLAMA_MODELS", t.TempDir())
t.Setenv("OLLAMA_GO_TEMPLATE", "")
+26 -15
View File
@@ -77,11 +77,27 @@ func chatPrompt(ctx context.Context, m *Model, tokenize tokenizeFunc, opts *api.
slog.Debug("truncating input messages which exceed context length", "truncated", len(msgs[currMsgIdx:]))
}
renderMsgs := slices.Clone(msgs)
renderMsgs, media, err := imageTaggedMessages(m, msgs, currMsgIdx, false)
if err != nil {
return "", nil, err
}
for cnt, msg := range renderMsgs[currMsgIdx:] {
// truncate any messages that do not fit into the context window
p, err := renderPrompt(m, append(system, renderMsgs[currMsgIdx:]...), tools, think)
if err != nil {
return "", nil, err
}
return p, media, nil
}
func imageTaggedMessages(m *Model, msgs []api.Message, start int, clearImages bool) ([]api.Message, []llm.MediaData, error) {
renderMsgs := slices.Clone(msgs)
var media []llm.MediaData
for cnt, msg := range renderMsgs[start:] {
if slices.Contains(m.Config.ModelFamilies, "mllama") && len(msg.Images) > 1 {
return "", nil, errors.New("this model only supports one image while more than one image requested")
return nil, nil, errors.New("this model only supports one image while more than one image requested")
}
var prefix string
@@ -105,20 +121,15 @@ func chatPrompt(ctx context.Context, m *Model, tokenize tokenizeFunc, opts *api.
}
}
if m.Config.Renderer != "" {
continue
if m.Config.Renderer == "" {
renderMsgs[start+cnt].Content = prefix + prompt
}
if clearImages {
renderMsgs[start+cnt].Images = nil
}
}
renderMsgs[currMsgIdx+cnt].Content = prefix + prompt
}
// truncate any messages that do not fit into the context window
p, err := renderPrompt(m, append(system, renderMsgs[currMsgIdx:]...), tools, think)
if err != nil {
return "", nil, err
}
return p, media, nil
return renderMsgs, media, nil
}
func renderPrompt(m *Model, msgs []api.Message, tools []api.Tool, think *api.ThinkValue) (string, error) {
+51 -5
View File
@@ -567,6 +567,47 @@ func (s *Server) GenerateHandler(c *gin.Context) {
// support for generate
if values.Messages != nil && values.Suffix == "" && req.Template == "" {
genTruncate := (req.Truncate == nil || *req.Truncate) && !m.IsMLX()
if m.HasChatTemplate && chatModeForModel(m) == chatExecutionModeNative {
nativeReq, err := prepareNativeChatRequest(c.Request.Context(), m, r, opts, llm.ChatRequest{
Messages: values.Messages,
Format: req.Format,
Options: opts,
Think: req.Think,
Shift: req.Shift == nil || *req.Shift,
Logprobs: req.Logprobs,
TopLogprobs: req.TopLogprobs,
}, genTruncate)
if err != nil {
slog.Error("chat template prompt error", "error", err)
var serr api.StatusError
if errors.As(err, &serr) {
c.JSON(serr.StatusCode, gin.H{"error": serr.ErrorMessage})
} else {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
}
return
}
nativeReq.Messages, media, err = imageTaggedMessages(m, nativeReq.Messages, 0, true)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
prompt, err = r.ApplyChatTemplate(c.Request.Context(), nativeReq)
if err != nil {
var serr api.StatusError
if errors.As(err, &serr) {
c.JSON(serr.StatusCode, gin.H{"error": serr.ErrorMessage})
} else {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
}
return
}
// TEMP(drifkin): req.Context will be removed very soon, but we're temporarily supporting it in this flow here
if req.Context != nil {
b.WriteString(prompt)
prompt = b.String()
}
} else {
prompt, media, err = chatPrompt(c.Request.Context(), m, r.Tokenize, optionsForPrompt(opts, r), values.Messages, []api.Tool{}, req.Think, genTruncate)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
@@ -578,6 +619,7 @@ func (s *Server) GenerateHandler(c *gin.Context) {
prompt = b.String()
}
leadingBOS = leadingBOSForModel(m)
}
} else {
// Direct template execution flow.
if err := tmpl.Execute(&b, values); err != nil {
@@ -2917,8 +2959,15 @@ func (s *Server) ChatHandler(c *gin.Context) {
writeChatResponse(c, req, ch)
}
func prepareNativeChatRequest(ctx context.Context, m *Model, r llm.LlamaServer, opts *api.Options, nativeReq llm.ChatRequest, truncate bool) (llm.ChatRequest, error) {
var err error
nativeReq.Messages, err = truncateNativeChatMessages(ctx, m, r, optionsForPrompt(opts, r), nativeReq, truncate)
return nativeReq, err
}
func (s *Server) handleNativeChat(c *gin.Context, req api.ChatRequest, m *Model, r llm.LlamaServer, opts *api.Options, msgs []api.Message, checkpointStart, checkpointLoaded time.Time) {
nativeReq := llm.ChatRequest{
truncate := req.Truncate == nil || *req.Truncate
nativeReq, err := prepareNativeChatRequest(c.Request.Context(), m, r, opts, llm.ChatRequest{
Messages: msgs,
Tools: req.Tools,
Format: req.Format,
@@ -2927,10 +2976,7 @@ func (s *Server) handleNativeChat(c *gin.Context, req api.ChatRequest, m *Model,
Shift: req.Shift == nil || *req.Shift,
Logprobs: req.Logprobs,
TopLogprobs: req.TopLogprobs,
}
truncate := req.Truncate == nil || *req.Truncate
var err error
nativeReq.Messages, err = truncateNativeChatMessages(c.Request.Context(), m, r, optionsForPrompt(opts, r), nativeReq, truncate)
}, truncate)
if err != nil {
slog.Error("chat template prompt error", "error", err)
var serr api.StatusError
+202
View File
@@ -62,6 +62,7 @@ type mockRunner struct {
ChatFn func(context.Context, llm.ChatRequest, func(llm.ChatResponse)) error
Template string
TemplateFn func(context.Context, llm.ChatRequest) (string, error)
DetokenizeFn func(context.Context, []int) (string, error)
contextLength int
}
@@ -91,6 +92,13 @@ func (m *mockRunner) ApplyChatTemplate(ctx context.Context, r llm.ChatRequest) (
return m.Template, nil
}
func (m *mockRunner) Detokenize(ctx context.Context, tokens []int) (string, error) {
if m.DetokenizeFn != nil {
return m.DetokenizeFn(ctx, tokens)
}
return "", nil
}
func (mockRunner) Tokenize(_ context.Context, s string) (tokens []int, err error) {
for range strings.Fields(s) {
tokens = append(tokens, len(tokens))
@@ -465,6 +473,200 @@ func TestChatHandlerHarmonyPreservesStructuralTokens(t *testing.T) {
}
}
func TestGenerateHandlerChatTemplateRoute(t *testing.T) {
t.Setenv("OLLAMA_CONTEXT_LENGTH", "4096")
t.Setenv("OLLAMA_GO_TEMPLATE", "")
gin.SetMode(gin.TestMode)
t.Run("uses GGUF chat_template when no Go TEMPLATE exists", func(t *testing.T) {
mock := mockRunner{
TemplateFn: func(_ context.Context, req llm.ChatRequest) (string, error) {
if len(req.Messages) != 1 || req.Messages[0].Role != "user" || req.Messages[0].Content != "hello" {
t.Fatalf("chat template messages = %#v", req.Messages)
}
return "native-template: hello", nil
},
CompletionResponse: llm.CompletionResponse{
Content: "ok",
Done: true,
DoneReason: llm.DoneReasonStop,
PromptEvalCount: 1,
PromptEvalDuration: time.Millisecond,
EvalCount: 1,
EvalDuration: time.Millisecond,
},
}
s := newServerWithMockRunner(t, &mock)
createMinimalGGUFModel(t, s, "generate-chat-template", ggml.KV{
"tokenizer.chat_template": "{{ messages[0]['content'] }}",
}, "", nil)
stream := false
w := createRequest(t, s.GenerateHandler, api.GenerateRequest{
Model: "generate-chat-template",
Prompt: "hello",
Stream: &stream,
})
if w.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d: %s", w.Code, w.Body.String())
}
if mock.CompletionRequest.Prompt != "native-template: hello" {
t.Fatalf("completion prompt = %q, want native chat_template prompt", mock.CompletionRequest.Prompt)
}
if mock.CompletionRequest.LeadingBOS != "" {
t.Fatalf("native chat_template prompt should not add leading BOS, got %q", mock.CompletionRequest.LeadingBOS)
}
})
t.Run("uses preferred GGUF chat_template over Go TEMPLATE", func(t *testing.T) {
mock := mockRunner{
TemplateFn: func(_ context.Context, req llm.ChatRequest) (string, error) {
if len(req.Messages) != 1 || req.Messages[0].Content != "hello" {
t.Fatalf("chat template messages = %#v", req.Messages)
}
return "preferred-native-template: hello", nil
},
}
s := newServerWithMockRunner(t, &mock)
createMinimalGGUFModel(t, s, "generate-preferred-chat-template", ggml.KV{
"tokenizer.chat_template": "{% if tools %}{{ tools }}{% endif %}{{ messages[0]['content'] }}",
}, "{{ range .Messages }}go-template: {{ .Content }}{{ end }}", nil)
w := createRequest(t, s.GenerateHandler, api.GenerateRequest{
Model: "generate-preferred-chat-template",
Prompt: "hello",
DebugRenderOnly: true,
})
if w.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d: %s", w.Code, w.Body.String())
}
var actual api.GenerateResponse
if err := json.Unmarshal(w.Body.Bytes(), &actual); err != nil {
t.Fatal(err)
}
if actual.DebugInfo == nil || actual.DebugInfo.RenderedTemplate != "preferred-native-template: hello" {
t.Fatalf("rendered template = %#v, want preferred native chat_template", actual.DebugInfo)
}
})
t.Run("falls back to default raw-like template without chat_template", func(t *testing.T) {
mock := mockRunner{
TemplateFn: func(context.Context, llm.ChatRequest) (string, error) {
t.Fatal("native chat template should not be used without tokenizer.chat_template")
return "", nil
},
}
s := newServerWithMockRunner(t, &mock)
createMinimalGGUFModel(t, s, "generate-no-chat-template", nil, "", nil)
w := createRequest(t, s.GenerateHandler, api.GenerateRequest{
Model: "generate-no-chat-template",
Prompt: "plain prompt",
DebugRenderOnly: true,
})
if w.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d: %s", w.Code, w.Body.String())
}
var actual api.GenerateResponse
if err := json.Unmarshal(w.Body.Bytes(), &actual); err != nil {
t.Fatal(err)
}
if actual.DebugInfo == nil || actual.DebugInfo.RenderedTemplate != "plain prompt" {
t.Fatalf("rendered template = %#v, want raw-like default template", actual.DebugInfo)
}
})
t.Run("prepends deprecated context to native chat template prompt", func(t *testing.T) {
mock := mockRunner{
TemplateFn: func(_ context.Context, req llm.ChatRequest) (string, error) {
if len(req.Messages) != 1 || req.Messages[0].Content != "next" {
t.Fatalf("chat template messages = %#v", req.Messages)
}
return "native-template: next", nil
},
DetokenizeFn: func(_ context.Context, tokens []int) (string, error) {
if !slices.Equal(tokens, []int{1, 2}) {
t.Fatalf("detokenize tokens = %#v", tokens)
}
return "prior context ", nil
},
CompletionResponse: llm.CompletionResponse{
Content: "ok",
Done: true,
DoneReason: llm.DoneReasonStop,
},
}
s := newServerWithMockRunner(t, &mock)
createMinimalGGUFModel(t, s, "generate-chat-template-context", ggml.KV{
"tokenizer.chat_template": "{{ messages[0]['content'] }}",
}, "", nil)
stream := false
w := createRequest(t, s.GenerateHandler, api.GenerateRequest{
Model: "generate-chat-template-context",
Prompt: "next",
Context: []int{1, 2},
Stream: &stream,
})
if w.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d: %s", w.Code, w.Body.String())
}
if mock.CompletionRequest.Prompt != "prior context native-template: next" {
t.Fatalf("completion prompt = %q, want context-prefixed native chat_template prompt", mock.CompletionRequest.Prompt)
}
})
t.Run("keeps generate media markers with native chat template", func(t *testing.T) {
image1 := []byte("image-one")
image2 := []byte("image-two")
mock := mockRunner{
TemplateFn: func(_ context.Context, req llm.ChatRequest) (string, error) {
if len(req.Messages) != 1 {
t.Fatalf("chat template messages = %#v", req.Messages)
}
if len(req.Messages[0].Images) != 0 {
t.Fatalf("native generate render request should carry image markers, not image payloads: %#v", req.Messages[0])
}
return req.Messages[0].Content, nil
},
CompletionResponse: llm.CompletionResponse{
Content: "ok",
Done: true,
DoneReason: llm.DoneReasonStop,
},
}
s := newServerWithMockRunner(t, &mock)
createMinimalGGUFModel(t, s, "generate-chat-template-images", ggml.KV{
"tokenizer.chat_template": "{{ messages[0]['content'] }}",
}, "", nil)
stream := false
w := createRequest(t, s.GenerateHandler, api.GenerateRequest{
Model: "generate-chat-template-images",
Prompt: "compare [img] and [img]",
Images: []api.ImageData{image1, image2},
Stream: &stream,
})
if w.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d: %s", w.Code, w.Body.String())
}
if mock.CompletionRequest.Prompt != "compare [img-0] and [img-1]" {
t.Fatalf("completion prompt = %q, want image marker prompt", mock.CompletionRequest.Prompt)
}
if len(mock.CompletionRequest.Media) != 2 {
t.Fatalf("completion media = %#v, want two images", mock.CompletionRequest.Media)
}
if mock.CompletionRequest.Media[0].ID != 0 || !bytes.Equal(mock.CompletionRequest.Media[0].Data, image1) {
t.Fatalf("first image media = %#v", mock.CompletionRequest.Media[0])
}
if mock.CompletionRequest.Media[1].ID != 1 || !bytes.Equal(mock.CompletionRequest.Media[1].Data, image2) {
t.Fatalf("second image media = %#v", mock.CompletionRequest.Media[1])
}
})
}
func TestGenerateChatRemote(t *testing.T) {
gin.SetMode(gin.TestMode)