Repository: ollama/ollama
Pull request: model/renderers/glmocr: inject image tags in renderer prompt path

## Summary

Fix `glm-ocr` image handling in renderer mode by injecting indexed image tags (`[img-{n}]`) into user content, matching the server renderer contract used by other multimodal renderers.

This resolves the regression where `glm-ocr` receives only text prompt content (no image placeholders), causing blank OCR output such as:

```markdown

```

## Problem

Since `v0.17.1`, `glm-ocr` can return empty markdown for image inputs while other VLM models still work.

In renderer mode (`m.Config.Renderer != ""`), `server/chatPrompt` does not rewrite message content with image tags and expects renderer-specific handling.
`glm-ocr` renderer did not prepend image markers, so image embeddings were not correctly wired into the rendered prompt flow.

## Root Cause

`GlmOcrRenderer` ignored `message.Images` and only rendered `message.Content`, unlike other renderer-based multimodal implementations (`qwen3vl`, `lfm2`) that emit image placeholders.

## Changes

- `model/renderers/glmocr.go`
  - add `useImgTags` behavior
  - prepend `[img-{offset}]` for each image in user messages
  - maintain multi-turn image offset continuity
- `model/renderers/renderer.go`
  - wire `glm-ocr` renderer with `RenderImgTags` (`&GlmOcrRenderer{useImgTags: RenderImgTags}`)
- `model/renderers/glmocr_test.go` (new)
  - add unit tests for single/multiple images, multi-turn offset, default/no-tag behavior, and no-image content passthrough
- `server/prompt_test.go`
  - add regression test ensuring `chatPrompt` + `glm-ocr` renderer path includes image tags

## Validation

- `go test ./model/renderers/...`
- `go test ./server -run "TestChatPrompt|TestChatPromptRendererDoesNotRewriteMessageContent|TestChatPromptGLMOcrRendererAddsImageTags|TestGenerateWithImages"`
- local end-to-end check with `glm-ocr:latest` + image input (`/api/generate` and `/api/chat`) returns non-empty OCR output

## Related

Fixes #14474
Related (different root cause): #14117 / #14523
Duplicate report: #14494
Potentially mixed-symptom thread: #14498

Changed files:
filename: model/renderers/glmocr.go
patch: @@ -8,7 +8,21 @@ import (
 	"github.com/ollama/ollama/api"
 )
 
-type GlmOcrRenderer struct{}
+type GlmOcrRenderer struct {
+	useImgTags bool
+}
+
+func (r *GlmOcrRenderer) renderContent(message api.Message, imageOffset int) (string, int) {
+	var sb strings.Builder
+	for range message.Images {
+		if r.useImgTags {
+			sb.WriteString(fmt.Sprintf("[img-%d]", imageOffset))
+			imageOffset++
+		}
+	}
+	sb.WriteString(message.Content)
+	return sb.String(), imageOffset
+}
 
 func (r *GlmOcrRenderer) Render(messages []api.Message, tools []api.Tool, thinkValue *api.ThinkValue) (string, error) {
 	var sb strings.Builder
@@ -38,11 +52,14 @@ func (r *GlmOcrRenderer) Render(messages []api.Message, tools []api.Tool, thinkV
 		thinkingExplicitlySet = true
 	}
 
+	imageOffset := 0
 	for i, message := range messages {
 		switch message.Role {
 		case "user":
 			sb.WriteString("
\n")
-			sb.WriteString(message.Content)
+			content, nextOffset := r.renderContent(message, imageOffset)
+			imageOffset = nextOffset
+			sb.WriteString(content)
 			if thinkingExplicitlySet && !enableThinking && !strings.HasSuffix(message.Content, "/nothink") {
 				sb.WriteString("/nothink")
 			}

filename: model/renderers/glmocr_test.go
patch: @@ -0,0 +1,99 @@
+package renderers
+
+import (
+	"testing"
+
+	"github.com/google/go-cmp/cmp"
+
+	"github.com/ollama/ollama/api"
+)
+
+func TestGlmOcrRenderer_Images(t *testing.T) {
+	tests := []struct {
+		name     string
+		renderer *GlmOcrRenderer
+		messages []api.Message
+		expected string
+	}{
+		{
+			name:     "use_img_tags_single_image",
+			renderer: &GlmOcrRenderer{useImgTags: true},
+			messages: []api.Message{
+				{
+					Role:    "user",
+					Content: "Describe this image.",
+					Images:  []api.ImageData{api.ImageData("img1")},
+				},
+			},
+			expected: "[gMASK]<sop>
\n[img-0]Describe this image.
\n",
+		},
+		{
+			name:     "use_img_tags_multiple_images",
+			renderer: &GlmOcrRenderer{useImgTags: true},
+			messages: []api.Message{
+				{
+					Role:    "user",
+					Content: "Describe these images.",
+					Images:  []api.ImageData{api.ImageData("img1"), api.ImageData("img2")},
+				},
+			},
+			expected: "[gMASK]<sop>
\n[img-0][img-1]Describe these images.
\n",
+		},
+		{
+			name:     "multi_turn_increments_image_offset",
+			renderer: &GlmOcrRenderer{useImgTags: true},
+			messages: []api.Message{
+				{
+					Role:    "user",
+					Content: "First image",
+					Images:  []api.ImageData{api.ImageData("img1")},
+				},
+				{
+					Role:    "assistant",
+					Content: "Processed.",
+				},
+				{
+					Role:    "user",
+					Content: "Second image",
+					Images:  []api.ImageData{api.ImageData("img2")},
+				},
+			},
+			expected: "[gMASK]<sop>
\n[img-0]First image
\n<think></think>\nProcessed.\n
\n[img-1]Second image
\n",
+		},
+		{
+			name:     "default_no_img_tags",
+			renderer: &GlmOcrRenderer{},
+			messages: []api.Message{
+				{
+					Role:    "user",
+					Content: "No image tags expected.",
+					Images:  []api.ImageData{api.ImageData("img1")},
+				},
+			},
+			expected: "[gMASK]<sop>
\nNo image tags expected.
\n",
+		},
+		{
+			name:     "no_images_content_unchanged",
+			renderer: &GlmOcrRenderer{useImgTags: true},
+			messages: []api.Message{
+				{
+					Role:    "user",
+					Content: "Text only message.",
+				},
+			},
+			expected: "[gMASK]<sop>
\nText only message.
\n",
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			got, err := tt.renderer.Render(tt.messages, nil, nil)
+			if err != nil {
+				t.Fatalf("Render() error = %v", err)
+			}
+			if diff := cmp.Diff(tt.expected, got); diff != "" {
+				t.Fatalf("Render() mismatch (-want +got):\n%s", diff)
+			}
+		})
+	}
+}

filename: model/renderers/renderer.go
patch: @@ -86,7 +86,7 @@ func rendererForName(name string) Renderer {
 	case "glm-4.7":
 		return &GLM47Renderer{}
 	case "glm-ocr":
-		return &GlmOcrRenderer{}
+		return &GlmOcrRenderer{useImgTags: RenderImgTags}
 	case "lfm2":
 		return &LFM2Renderer{IsThinking: false, useImgTags: RenderImgTags}
 	case "lfm2-thinking":

filename: server/prompt_test.go
patch: @@ -3,6 +3,7 @@ package server
 import (
 	"bytes"
 	"context"
+	"strings"
 	"testing"
 
 	"github.com/google/go-cmp/cmp"
@@ -366,3 +367,33 @@ func TestChatPromptRendererDoesNotRewriteMessageContent(t *testing.T) {
 		t.Fatal("prompt is empty")
 	}
 }
+
+func TestChatPromptGLMOcrRendererAddsImageTags(t *testing.T) {
+	msgs := []api.Message{
+		{
+			Role:    "user",
+			Content: "extract text",
+			Images:  []api.ImageData{[]byte("img-1"), []byte("img-2")},
+		},
+	}
+
+	m := Model{
+		Config:         model.ConfigV2{Renderer: "glm-ocr"},
+		ProjectorPaths: []string{"vision"},
+	}
+	opts := api.Options{Runner: api.Runner{NumCtx: 8192}}
+	think := false
+
+	prompt, images, err := chatPrompt(t.Context(), &m, mockRunner{}.Tokenize, &opts, msgs, nil, &api.ThinkValue{Value: think}, true)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	if got, want := len(images), 2; got != want {
+		t.Fatalf("len(images) = %d, want %d", got, want)
+	}
+
+	if !strings.Contains(prompt, "
\n[img-0][img-1]extract text") {
+		t.Fatalf("prompt missing glm-ocr image tags, got: %q", prompt)
+	}
+}