@@ -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+
6985func (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.
276295func 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.
286337func (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