Skip to content

Commit 47980bc

Browse files
mudlerlocalai-org-maint-bot
authored andcommitted
feat(dllm): image input through the backend (multimodal C-ABI)
Routes PredictOptions.Images (raw base64, the core convention) through dllm.cpp's probed multimodal entry points as data: URIs; the gemma4 renderer appends one engine-side <image> marker per image after the last user message (llama.cpp attachment convention; the template's content-parts branch is unreachable through the flattened pb shape). The engine expands markers to boi + soft*n + eoi and splices the vision-tower embeddings. Older libdllm.so without the mm symbols fails with an actionable error (Dlsym probe). DLLM_VERSION pin bumped to the engine's vision-capable commit. Assisted-by: Claude Code (Fable 5) Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
1 parent 157d608 commit 47980bc

8 files changed

Lines changed: 662 additions & 43 deletions

File tree

backend/go/dllm/Makefile

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,11 @@
1919
# until then the anonymous clone below fails. Use the symlink shortcut above
2020
# with a local checkout, or a git credential helper with access to the repo.
2121

22-
DLLM_VERSION?=b22fcebebfb225131113188599a9ae542b2935d7
22+
# The pin below is the first commit carrying the multimodal C-ABI entry
23+
# points (dllm_capi_generate_mm / dllm_capi_generate_stream_mm) the
24+
# image-input path probes for; older libs still load, but image requests
25+
# then fail with "library predates the multimodal entry points".
26+
DLLM_VERSION?=e6dcf44cddd65845e3a0814a1c2282a5d90ee98a
2327
DLLM_REPO?=https://github.com/mudler/dllm.cpp
2428

2529
GOCMD?=go

backend/go/dllm/capi.go

Lines changed: 78 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ package main
1616

1717
import (
1818
"encoding/json"
19+
"errors"
1920
"fmt"
2021
"sync"
2122
"sync/atomic"
@@ -45,6 +46,34 @@ var (
4546
cppCancel func(ctx uintptr)
4647
)
4748

49+
// Optional multimodal entry points (dllm_capi.h's P4 surface). The ABI
50+
// version stays 1: presence is detected by PROBING the symbols with Dlsym at
51+
// boot (loadCAPI, mirroring the parakeet-cpp optional-symbol pattern). nil
52+
// means the loaded libdllm.so predates the mm surface; the wrappers below
53+
// then fail with errMMUnsupported instead of crashing on a nil call.
54+
var (
55+
cppGenerateMM func(ctx uintptr, prompt, imagesJSON, optsJSON string) uintptr
56+
cppGenerateStreamMM func(ctx uintptr, prompt, imagesJSON, optsJSON string, onBlock, onStep, userData uintptr) int32
57+
)
58+
59+
// mmImageMarker is the literal placeholder dllm_capi_generate_mm expands to
60+
// <boi> + soft-token placeholders + <eoi> (dllm_capi.h placeholder contract;
61+
// capi.cpp MM_MARKER). The prompt must carry exactly one marker per
62+
// images_json entry, in image order.
63+
const mmImageMarker = "<image>"
64+
65+
// errMMUnsupported is returned for image-bearing requests against an old
66+
// text-only libdllm.so (the Dlsym probe found no mm symbols).
67+
var errMMUnsupported = errors.New(
68+
"dllm: image input requires libdllm.so with the multimodal entry points (dllm_capi_generate_mm), but the loaded library predates them - rebuild/upgrade the dllm backend to use images")
69+
70+
// cMMSupported reports whether the loaded libdllm.so carries the multimodal
71+
// generate pair. Both symbols ship together (same dllm.cpp commit), but the
72+
// guard requires both anyway so a half-present surface can never dispatch.
73+
func cMMSupported() bool {
74+
return cppGenerateMM != nil && cppGenerateStreamMM != nil
75+
}
76+
4877
// cAbiVersion returns the library's DLLM_CAPI_ABI_VERSION.
4978
func cAbiVersion() int32 {
5079
return cppAbiVersion()
@@ -108,6 +137,23 @@ func cGenerate(h uintptr, prompt, optsJSON string) (string, error) {
108137
return out, nil
109138
}
110139

140+
// cGenerateMM is cGenerate's multimodal counterpart. imagesJSON is the flat
141+
// JSON array of image entries (data: base64 URIs here; the C side also takes
142+
// file paths) and the prompt must carry one mmImageMarker per entry - the
143+
// engine enforces the 1:1 match and reports mismatches through last_error.
144+
func cGenerateMM(h uintptr, prompt, imagesJSON, optsJSON string) (string, error) {
145+
if !cMMSupported() {
146+
return "", errMMUnsupported
147+
}
148+
ret := cppGenerateMM(h, prompt, imagesJSON, optsJSON)
149+
if ret == 0 {
150+
return "", fmt.Errorf("dllm: generate_mm failed: %s", lastErrorOr(h, "unknown error"))
151+
}
152+
out := goStringFromCPtr(ret)
153+
cppFreeString(ret)
154+
return out, nil
155+
}
156+
111157
// streamCallState carries the Go callbacks for one in-flight
112158
// cGenerateStream call; the registry key travels through C as user_data.
113159
// The map shape mirrors the whisper backend's streamCallStates: only one
@@ -158,11 +204,12 @@ func onStepTrampoline(step int32, totalSteps int32, canvasPreview uintptr, userD
158204
}
159205
}
160206

161-
// cGenerateStream runs a generation with per-committed-block (onBlock) and
162-
// per-denoising-step (onStep) callbacks; either may be nil. The callbacks
163-
// run on the C thread (see the trampoline docs). Returns an error carrying
164-
// last_error on failure; cancellation surfaces as the "cancelled" message.
165-
func cGenerateStream(h uintptr, prompt, optsJSON string, onBlock func(text string), onStep func(step, total int, preview string)) error {
207+
// withStreamCallbacks registers onBlock/onStep in the trampoline registry
208+
// for the duration of one streaming C call and invokes call with the C
209+
// function pointers (NULL for absent callbacks, so the C side skips the
210+
// per-block / per-step detokenize work entirely) plus the registry key to
211+
// pass as user_data. Shared by the text and multimodal stream wrappers.
212+
func withStreamCallbacks(onBlock func(text string), onStep func(step, total int, preview string), call func(blockPtr, stepPtr, userData uintptr) int32) int32 {
166213
streamCbOnce.Do(func() {
167214
blockCbPtr = purego.NewCallback(onBlockTrampoline)
168215
stepCbPtr = purego.NewCallback(onStepTrampoline)
@@ -172,22 +219,45 @@ func cGenerateStream(h uintptr, prompt, optsJSON string, onBlock func(text strin
172219
streamCallStates.Store(id, &streamCallState{onBlock: onBlock, onStep: onStep})
173220
defer streamCallStates.Delete(id)
174221

175-
// Pass NULL for absent callbacks so the C side skips the per-block /
176-
// per-step detokenize work entirely.
177222
var blockPtr, stepPtr uintptr
178223
if onBlock != nil {
179224
blockPtr = blockCbPtr
180225
}
181226
if onStep != nil {
182227
stepPtr = stepCbPtr
183228
}
229+
return call(blockPtr, stepPtr, uintptr(id))
230+
}
184231

185-
if rc := cppGenerateStream(h, prompt, optsJSON, blockPtr, stepPtr, uintptr(id)); rc != 0 {
232+
// cGenerateStream runs a generation with per-committed-block (onBlock) and
233+
// per-denoising-step (onStep) callbacks; either may be nil. The callbacks
234+
// run on the C thread (see the trampoline docs). Returns an error carrying
235+
// last_error on failure; cancellation surfaces as the "cancelled" message.
236+
func cGenerateStream(h uintptr, prompt, optsJSON string, onBlock func(text string), onStep func(step, total int, preview string)) error {
237+
rc := withStreamCallbacks(onBlock, onStep, func(blockPtr, stepPtr, userData uintptr) int32 {
238+
return cppGenerateStream(h, prompt, optsJSON, blockPtr, stepPtr, userData)
239+
})
240+
if rc != 0 {
186241
return fmt.Errorf("dllm: generate_stream failed: %s", lastErrorOr(h, "unknown error"))
187242
}
188243
return nil
189244
}
190245

246+
// cGenerateStreamMM is cGenerateStream's multimodal counterpart; see
247+
// cGenerateMM for the imagesJSON/marker contract.
248+
func cGenerateStreamMM(h uintptr, prompt, imagesJSON, optsJSON string, onBlock func(text string), onStep func(step, total int, preview string)) error {
249+
if !cMMSupported() {
250+
return errMMUnsupported
251+
}
252+
rc := withStreamCallbacks(onBlock, onStep, func(blockPtr, stepPtr, userData uintptr) int32 {
253+
return cppGenerateStreamMM(h, prompt, imagesJSON, optsJSON, blockPtr, stepPtr, userData)
254+
})
255+
if rc != 0 {
256+
return fmt.Errorf("dllm: generate_stream_mm failed: %s", lastErrorOr(h, "unknown error"))
257+
}
258+
return nil
259+
}
260+
191261
// cCancel requests cancellation of the in-flight generate on h. This is the
192262
// ONE entry point safe to call from any goroutine while a generate runs (it
193263
// only flips an atomic). Note the cancel-reset race from the header: each

backend/go/dllm/dllm.go

Lines changed: 85 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,13 @@ type generator interface {
4242
// generateStream invokes onBlock once per committed diffusion block, on
4343
// the thread running the C call, before returning.
4444
generateStream(prompt, optsJSON string, onBlock func(text string)) error
45+
// generateMM / generateStreamMM are the multimodal counterparts:
46+
// imagesJSON is a flat JSON array of data: base64 URIs and the prompt
47+
// carries one mmImageMarker per entry (dllm_capi.h placeholder
48+
// contract). Against an old text-only libdllm.so they fail with
49+
// errMMUnsupported.
50+
generateMM(prompt, imagesJSON, optsJSON string) (string, error)
51+
generateStreamMM(prompt, imagesJSON, optsJSON string, onBlock func(text string)) error
4552
tokenizeJSON(text string) (string, error)
4653
// cancel is the ONE entry point safe to call concurrently with an
4754
// in-flight generate on the same ctx (dllm_capi.h: it only flips an
@@ -66,6 +73,15 @@ func (g *capiGenerator) generateStream(prompt, optsJSON string, onBlock func(tex
6673
return cGenerateStream(g.h, prompt, optsJSON, onBlock, nil)
6774
}
6875

76+
func (g *capiGenerator) generateMM(prompt, imagesJSON, optsJSON string) (string, error) {
77+
return cGenerateMM(g.h, prompt, imagesJSON, optsJSON)
78+
}
79+
80+
func (g *capiGenerator) generateStreamMM(prompt, imagesJSON, optsJSON string, onBlock func(text string)) error {
81+
// on_step is nil for the same reason as generateStream.
82+
return cGenerateStreamMM(g.h, prompt, imagesJSON, optsJSON, onBlock, nil)
83+
}
84+
6985
func (g *capiGenerator) tokenizeJSON(text string) (string, error) {
7086
return cTokenizeJSON(g.h, text)
7187
}
@@ -267,20 +283,55 @@ func metadataEnableThinking(opts *pb.PredictOptions) bool {
267283
}
268284

269285
// buildPrompt resolves the prompt for a request. With use_tokenizer_template
270-
// and raw messages the backend owns templating (RenderGemma4) and the output
271-
// is in the known gemma4 format, so parse=true. Without it the caller
272-
// templated the prompt themselves (LocalAI's Go templates + PEG fallback, or
273-
// a bare completion): the prompt passes through verbatim and the output is
274-
// NOT gemma4-parsed - it is emitted as plain content and the Go side's
275-
// extraction applies, as for any non-autoparsing backend.
286+
// and raw messages the backend owns templating (RenderGemma4, including the
287+
// mmImageMarker injection for opts.Images) and the output is in the known
288+
// gemma4 format, so parse=true. Without it the caller templated the prompt
289+
// themselves (LocalAI's Go templates + PEG fallback, or a bare completion):
290+
// the prompt passes through verbatim - for image requests it must already
291+
// carry one literal mmImageMarker per image (the engine enforces the 1:1
292+
// match) - and the output is NOT gemma4-parsed - it is emitted as plain
293+
// content and the Go side's extraction applies, as for any non-autoparsing
294+
// backend.
276295
func buildPrompt(opts *pb.PredictOptions) (prompt string, parse bool, err error) {
277296
if opts.GetUseTokenizerTemplate() && len(opts.GetMessages()) > 0 {
278-
prompt, err = RenderGemma4(opts.GetMessages(), opts.GetTools(), metadataEnableThinking(opts), true)
297+
prompt, err = RenderGemma4(opts.GetMessages(), opts.GetTools(), len(opts.GetImages()), metadataEnableThinking(opts), true)
279298
return prompt, true, err
280299
}
281300
return opts.GetPrompt(), false, nil
282301
}
283302

303+
// imagesJSON renders opts.Images as the flat JSON array of data: URIs the mm
304+
// C-ABI expects, or "" when the request carries no images. The entries arrive
305+
// as RAW base64 payloads: LocalAI's OpenAI layer decodes every image_url /
306+
// image content part (URL download or data: URI) to plain base64 via
307+
// utils.GetContentURIAsBase64 (core/http/middleware/request.go) and core
308+
// flattens them into PredictOptions.Images (core/backend/llm.go). The
309+
// hardcoded image/jpeg mime mirrors the llama.cpp backend's re-wrapping
310+
// convention (grpc-server.cpp, "data:image/jpeg;base64," + images(i)); the
311+
// engine ignores the declared mime and sniffs the real format from the
312+
// decoded bytes (stb_image), so PNG/BMP payloads work through it too.
313+
func imagesJSON(images []string) (string, error) {
314+
if len(images) == 0 {
315+
return "", nil
316+
}
317+
uris := make([]string, len(images))
318+
for i, img := range images {
319+
// dllm_capi.h: array entries are read VERBATIM up to the closing
320+
// quote, with NO escape handling. json.Marshal would escape these
321+
// bytes and the C side would misparse the entry, so fail loud (they
322+
// can never appear in genuine base64 anyway).
323+
if strings.ContainsAny(img, "\"\\") {
324+
return "", fmt.Errorf("dllm: image %d is not base64 (contains a quote or backslash; PredictOptions.Images entries must be raw base64 payloads)", i)
325+
}
326+
uris[i] = "data:image/jpeg;base64," + img
327+
}
328+
b, err := json.Marshal(uris)
329+
if err != nil {
330+
return "", fmt.Errorf("dllm: marshal images: %w", err)
331+
}
332+
return string(b), nil
333+
}
334+
284335
// requestOptsJSON merges the model-level overrides with the request's
285336
// sampling fields into the flat opts JSON for one generate call.
286337
func (d *Dllm) requestOptsJSON(opts *pb.PredictOptions) (string, error) {
@@ -307,17 +358,27 @@ func (d *Dllm) requestOptsJSON(opts *pb.PredictOptions) (string, error) {
307358

308359
// prepareRequest is the shared prologue of the rich methods: resolve the
309360
// prompt (and whether the output gets gemma4-parsed) and build the per-call
310-
// opts JSON.
311-
func (d *Dllm) prepareRequest(opts *pb.PredictOptions) (prompt string, parse bool, optsJSON string, err error) {
361+
// opts JSON plus the images JSON ("" for text-only requests, which routes
362+
// the call through the text generate entry points).
363+
func (d *Dllm) prepareRequest(opts *pb.PredictOptions) (prompt string, parse bool, optsJSON, imgJSON string, err error) {
364+
// Fail loud on media the engine has no path for, instead of silently
365+
// generating from a prompt that ignores them.
366+
if len(opts.GetVideos()) > 0 || len(opts.GetAudios()) > 0 {
367+
return "", false, "", "", errors.New("dllm: video/audio input is not supported (images only)")
368+
}
312369
prompt, parse, err = buildPrompt(opts)
313370
if err != nil {
314-
return "", false, "", err
371+
return "", false, "", "", err
315372
}
316373
optsJSON, err = d.requestOptsJSON(opts)
317374
if err != nil {
318-
return "", false, "", err
375+
return "", false, "", "", err
319376
}
320-
return prompt, parse, optsJSON, nil
377+
imgJSON, err = imagesJSON(opts.GetImages())
378+
if err != nil {
379+
return "", false, "", "", err
380+
}
381+
return prompt, parse, optsJSON, imgJSON, nil
321382
}
322383

323384
// sanitizeUTF8 makes s safe for a proto3 string field. Block-boundary
@@ -386,15 +447,19 @@ func (d *Dllm) PredictRich(opts *pb.PredictOptions) (*pb.Reply, error) {
386447
if d.gen == nil {
387448
return nil, grpcerrors.ModelNotLoaded("dllm")
388449
}
389-
prompt, parse, optsJSON, err := d.prepareRequest(opts)
450+
prompt, parse, optsJSON, imgJSON, err := d.prepareRequest(opts)
390451
if err != nil {
391452
return nil, err
392453
}
393454

394455
var out string
395456
var genErr error
396457
d.submit(func() {
397-
out, genErr = d.gen.generate(prompt, optsJSON)
458+
if imgJSON != "" {
459+
out, genErr = d.gen.generateMM(prompt, imgJSON, optsJSON)
460+
} else {
461+
out, genErr = d.gen.generate(prompt, optsJSON)
462+
}
398463
})
399464
if genErr != nil {
400465
return nil, genErr
@@ -429,7 +494,7 @@ func (d *Dllm) PredictStreamRich(opts *pb.PredictOptions, results chan<- *pb.Rep
429494
if d.gen == nil {
430495
return grpcerrors.ModelNotLoaded("dllm")
431496
}
432-
prompt, parse, optsJSON, err := d.prepareRequest(opts)
497+
prompt, parse, optsJSON, imgJSON, err := d.prepareRequest(opts)
433498
if err != nil {
434499
return err
435500
}
@@ -467,7 +532,11 @@ func (d *Dllm) PredictStreamRich(opts *pb.PredictOptions, results chan<- *pb.Rep
467532

468533
var genErr error
469534
d.submit(func() {
470-
genErr = d.gen.generateStream(prompt, optsJSON, onBlock)
535+
if imgJSON != "" {
536+
genErr = d.gen.generateStreamMM(prompt, imgJSON, optsJSON, onBlock)
537+
} else {
538+
genErr = d.gen.generateStream(prompt, optsJSON, onBlock)
539+
}
471540
})
472541
if genErr != nil {
473542
return genErr

0 commit comments

Comments
 (0)