Skip to content

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

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

stikves wants to merge 7 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 2 times, most recently from b033b63 to 111c4a0 Compare September 23, 2026 01:09
stikves and others added 7 commits September 23, 2026 11:06
Adds a JSONL replay path so the real generation stack can be exercised without
binding a socket (sandboxed CI, locked-down machines), and consolidates serving
and replay onto one code path.

Extract runChatCompletion from the non-streaming HTTP handler: it returns the
assembled ChatCompletionResponse plus per-request instrumentation
(ChatCompletionOutcome). Extract the streaming core the same way: prepareStreaming
does preflight/guards, runStreamingLoop drives the incremental think/tool parse
loop and delivers each delta to an emit closure. The SSE handler and --replay both
call these, so serve and replay share one generation core per mode.

Shared format in CoreAILMCommon/ReplayTypes.swift: ReplayRequest wraps the exact
ChatCompletionRequest plus a session id and arrival timestamp; ReplayResult is a
parseable JSONL line with content, reasoning_content, tool_calls, finish_reason,
system_fingerprint, token counts, prefix_reuse_tokens, ttft/total/decode timings,
and a streamed flag. ReplayIO parses JSONL (skips blanks/comments, orders by
timestamp, stable on ties) and serializes results.

Replay honors the request's stream field: stream:false runs runChatCompletion,
stream:true drives runStreamingLoop and folds the emitted deltas back into the
result via ReplayStreamAggregator, so streaming requests exercise the incremental
parse path. Requests run in arrival order; sequential by construction
(single-active engine), matching the real server.

llm-server --replay <in.jsonl> [--replay-output <out.jsonl>] loads and warms the
model exactly as serve does, then runs requests and writes results JSONL, never
binding the port.

Tests: ReplayTypesTests cover request decode, JSONL parse/order/skip, result
encoding (including reasoning_content/tool_calls/streamed), the isStreaming
decision, and ReplayStreamAggregator delta folding. Runner execution over a live
engine is not unit-tested (needs model assets); the pure pieces it composes are.
Replace ReplayResult's flat content/reasoning_content/tool_calls/finish_reason
with a choices array reusing ChatCompletionResponse.Choice, so results mirror
the response type and handle multiple messages. The streaming aggregator now
folds deltas into a single assistant Choice.
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 111c4a0 to b957b43 Compare September 23, 2026 20:17
@stikves
stikves marked this pull request as ready for review September 23, 2026 20:59
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