Skip to content

serve: the vision markers inside a message's text stay text and no longer take a picture's place (#150 for the whole marker) - #1045

Closed
sergqwer wants to merge 1 commit into
Niko1221:mainfrom
sergqwer:pr/image-markers
Closed

sergqwer wants to merge 1 commit into
Niko1221:mainfrom
sergqwer:pr/image-markers

Conversation

@sergqwer

@sergqwer sergqwer commented Oct 6, 2026

Copy link
Copy Markdown

Replaces #554. GitHub closed it on 2026-10-05, when my fork was made private by mistake: that took the fork out of the network for good, so it can no longer open pull requests. This is the same branch and commit (1273345), opened from a new fork; the discussion and the measurements are in #554.


Service.prepare() renders the chat template and tokenizes the whole prompt with parse_special=True. The template writes <|vision_start|><|image_pad|><|vision_end|> for each image item, and the #150 guard turns only an <|image_pad|> not preceded by <|vision_start|> back into text.

Message text that contains the whole marker pair becomes the same special ids as a real image. That happens with an agent reading serve/chat_template.jinja, these docs, or a tool result quoting them. If such text comes before a real picture:

  • picture 1's embedding rows go to the text's marker;
  • every later picture moves up one;
  • the last real marker becomes text.

The counts still match, so nothing reports it. With Claude Code reading a repository and a screenshot in one conversation, this is an ordinary case.

Fix (serve/server.py, Service.prompt_ids()):

  • The vision markers inside the messages' and tools' strings are swapped for a unique placeholder before rendering.
  • After rendering, each placeholder becomes its marker's literal (parse_special=False) tokens. The pieces between placeholders are tokenized as before, since a special token split the text there anyway.
  • A prompt without such text takes the old path: byte-identical ids, checked on the real tokenizer too.
  • count_tokens uses the same ids.
  • If text still yields more marker pairs than there are pictures (a marker cut between two text parts), that is now a 400 instead of a silent shift.

Test test_the_whole_marker_in_text_before_images: each picture's rows follow its own marker, and the text keeps its literal tokens. Without the fix it finds 3 vision_start where 2 belong. The #150 test is unchanged. Serve tests: 173 OK (7 skipped).

This touches the first lines of prepare(), as #529 does; whichever goes in second needs a one-line rebase.

🤖 Generated with Claude Code

https://claude.ai/code/session_01VZy1yKaDDiA8a7svdwaHio

…nger take a picture's place (Niko1221#150 follow-up)

The markers are special tokens only where the template writes them for an image item. The same strings in a
message's text (an agent reading chat_template.jinja, a tool result quoting it) became the same special ids, so text
with <|vision_start|><|image_pad|> before a picture took that picture's embeddings, every later picture moved up one
and the last real marker became text - every count still matched.

On 0.1.39 this takes Niko1221#537's path: the vision markers join <think> / </think> as literal tags (frontend.VISION_TAGS),
swapped for private-use marks before the template and encoded as text through the tokenizer's plain spans. A pair
of markers cut apart across text parts is refused ("the prompt and its images do not match") instead of shifting.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VZy1yKaDDiA8a7svdwaHio
@sergqwer

sergqwer commented Oct 6, 2026

Copy link
Copy Markdown
Author

Closing: this went into 0.1.40 from #554 (see the comment there). It was reopened here only because my old fork had gone private; thank you for taking it.

@sergqwer sergqwer closed this Oct 6, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant