Skip to content

Serve VLM bundles with image input over the chat API - #270

Closed
stikves wants to merge 5 commits into
apple:mainfrom
stikves:sukru/vlm-server-image-input
Closed

stikves wants to merge 5 commits into
apple:mainfrom
stikves:sukru/vlm-server-image-input

Conversation

@stikves

@stikves stikves commented Sep 20, 2026 •

Copy link
Copy Markdown
Contributor

Adds a vision-language path to llm-server so a kind=vlm bundle (e.g. muse-glimmer-30b) can be served over the OpenAI-compatible chat endpoint with image input.

What it does

  • LLMServerMain builds CoreAISequentialVLMEngine directly for kind=vlm bundles (EngineFactory only produces text engines) and skips the text-shaped warmup.
  • ServerState carries the VisionConfig and exposes the engine as a MultimodalInferenceEngine.
  • runVLMCompletion decodes one image (base64 data URL or local path), runs the vision encoder, expands the image placeholder in the prompt, and generates through the multimodal engine. It is routed from the shared runChatCompletion core, so the HTTP non-streaming handler and --replay both exercise it.
  • Output reuses the existing think-tag / tool-call parsing, so reasoning models return a clean final answer with the analysis separated into reasoning_content.
  • /v1/models reports supports_vision for VLM bundles.

File access & streaming

  • New --file-access flag controls whether an image_url may read local files. Default off rejects all bare paths and file:// URLs (only data: and http(s):// are accepted); subdirs allows local files only when the canonicalized real path resolves inside the server working directory subtree, rejecting .. traversal, absolute-outside paths, and symlink escapes. This closes an unrestricted local-file-read hole (a client could previously pass /etc/hosts).
  • A stream:true request that carries an image now returns a 400 ("Streaming is not supported for image (VLM) requests") instead of silently falling through to the text path and answering without the image.

Testing

  • New LLMServerTests target: decodeImage on a data URL and on a remote URL, buildPromptTokens placeholder expansion, and file-access policy coverage (off rejects /etc/hosts and file:///etc/hosts; subdirs allows inside-CWD paths and rejects traversal, absolute-outside, base-dir, and symlink-escape).
  • Validated with --replay on the muse-glimmer-30b VLM bundle: an image referenced by path is transcribed accurately, with the harmony analysis stripped from the answer.

Stacking

Stacks on the seed (#265) and --replay (#266) branches since the VLM core lives in the shared runChatCompletion; the diff includes those commits until they merge.

Deferred

Multiple images, video, http(s) URL fetch, guided/constrained decoding on the multimodal path, VLM prefix reuse, and the HTTP streaming VLM path (currently rejected with a 400).

@stikves
stikves force-pushed the sukru/vlm-server-image-input branch 3 times, most recently from 111c4a0 to b957b43 Compare September 23, 2026 20:17
@stikves
stikves marked this pull request as ready for review September 23, 2026 20:59
public struct ReplayRequest: Decodable, Sendable {
/// Caller-supplied id, echoed on the result. Optional.
public let id: String?
/// Session identity — maps to `X-Session-ID`. Defaults to "default" when absent.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Defaults to "default" when absent.

Is this true? If so, seems like it happens very downstream. Might be more clear to set the default in init

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was from the stacked PR, they should be merged now

if let outputPath {
try output.write(toFile: outputPath, atomically: true, encoding: .utf8)
} else {
print(output, terminator: "")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this will always print if outputPath is not defined. Desired? Or switch to CLILogger?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is desired, the parent process will grab the stdout. CLILogger is gated and not built for this purpose.

Comment thread swift/Sources/Tools/llm-server/VLMChatHandler.swift Outdated
Comment thread swift/Sources/Tools/llm-server/VLMChatSupport.swift Outdated
Add a vision-language path to llm-server so a kind=vlm bundle (e.g.
muse-glimmer-30b) can be served over the OpenAI-compatible chat endpoint with
image input.

- LLMServerMain builds CoreAISequentialVLMEngine directly for kind=vlm bundles
  (EngineFactory only produces text engines) and skips the text-shaped warmup.
- ServerState carries the VisionConfig and exposes the engine as a
  MultimodalInferenceEngine.
- runVLMCompletion decodes one image (base64 data URL or local path), runs the
  vision encoder, expands the image placeholder in the prompt, and generates
  through the multimodal engine. It is routed from the shared runChatCompletion
  core, so both the HTTP non-streaming handler and --replay exercise it.
- Output reuses the existing think-tag / tool-call parsing, so reasoning models
  return a clean final answer with the analysis separated into reasoning_content.
- /v1/models reports supports_vision for VLM bundles.

Validated with --replay on the muse-glimmer-30b VLM bundle: an image referenced
by path is transcribed accurately, with the harmony analysis stripped from the
answer.

Stacks on the seed (apple#265) and --replay (apple#266) branches since the VLM core lives
in the shared runChatCompletion; the diff includes those commits until they
merge.

Deferred: multiple images, video, http(s) URL fetch, guided/constrained decoding
on the multimodal path, VLM prefix reuse, and the HTTP streaming VLM path.
Add a --file-access flag (off by default, subdirs) controlling whether
image_url content parts may read local files. off rejects all bare paths
and file:// URLs; subdirs allows only canonicalized real paths inside the
server working directory, rejecting .. traversal, absolute-outside paths,
and symlink escapes. Thread the policy through ServerConfig into decodeImage.

Reject stream:true requests carrying an image with a 400 instead of
silently answering on the text path without the image.
Replace the inline kind == .vlm CoreAISequentialVLMEngine build in LLMServerMain
with the shared EngineFactory bundle entry point, which routes VLM bundles to the
sequential VLM engine and text bundles to the text engines. Chunking overrides
pass through engineOptions; the VLM warmup skip is unchanged.
@stikves
stikves force-pushed the sukru/vlm-server-image-input branch from 96962e1 to fc5e0ca Compare September 26, 2026 02:51
stikves and others added 2 commits September 25, 2026 20:00
Co-authored-by: kevchengcodes <59463423+kevchengcodes@users.noreply.github.com>
@stikves stikves self-assigned this Sep 26, 2026
@stikves stikves closed this Sep 30, 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.

2 participants