Developer Feedback: Image Inputs to llm.complete May Not Be Reaching the Model, and the Documentation Is Less Explicit Than for Agent Session

While testing image input support in an Anna App, I found that sending an image using the MCP image block structure described in the llm.complete documentation resulted in a successful response, but the model said it could not see the attachment. In the same test application, image inputs to Agent Session using uploaded image URLs have been verified to work correctly.

I would appreciate the team’s help in confirming the image input protocol for llm.complete and whether the current implementation matches the documentation.

1. Official Documentation and Areas That Need Clarification

Relevant documentation:

llm.complete

The LLM Host API documentation describes the request as MCP-shaped and states that messages[].content can be a string or an MCP-shaped content block:

content is a string OR an MCP-shaped block ({type:'text', text} / {type:'image', ...}); blocks are flattened into a LangChain HumanMessage / AIMessage / SystemMessage by _assemble_lc_messages.

The documentation also states:

Image content blocks require the selected model to be multimodal; otherwise the provider rejects upstream and the call surfaces as APP_PROVIDER_ERROR.

Based on this, I understand that llm.complete supports image inputs. However, the documentation uses {type:'image', ...} without specifying the image block’s fields and does not provide a complete image request example. This leaves the following details unclear:

  • Should images use the MCP data and mimeType fields?
  • Must data contain raw base64, or is a full data URI also accepted?
  • Are image URLs supported? If so, what structure should be used?
  • Can text and images be placed in separate, consecutive user messages?

Based on the documentation’s description of MCP, the current implementation uses { type: "image", data: "<base64>", mimeType: "image/png" }. This follows the MCP image block specification, but the Anna documentation itself does not show this request in full.

Agent Session

In comparison, the “Native image inputs (attachments)” section of the Agent Session documentation provides an explicit example:

const stream = sess.run({
  content: "Check these slides for text overflow or layout issues.",
  attachments: [
    { type: "image/png", url: "https://example.com/slide-1.png" },
    {
      type: "image/png",
      url: "https://example.com/slide-2.png",
      detail: "high"
    },
    {
      type: "image/jpeg",
      data: "<base64 or data: URI>",
      filename: "photo.jpg"
    }
  ]
});

This documentation explicitly describes both URL and base64 inputs and lists attachment limits and usage considerations, giving developers enough information to implement and verify the feature directly.

2. The Actual llm.complete Request

The test application calls llm.complete through Anna Runtime. The image is read in the browser using FileReader.readAsDataURL(). After removing the data URI prefix, the raw base64 is placed in an MCP image block.

The request structure is shown below, with the image data omitted. The Chinese prompt is translated into English here:

await runtime.call("llm", "complete", {
  messages: [
    {
      role: "user",
      content: {
        type: "text",
        text: "Can you see the contents of the image I attached?"
      }
    },
    {
      role: "user",
      content: {
        type: "image",
        data: "<full image base64 omitted here>",
        mimeType: "image/png"
      }
    }
  ]
});

This request does not use the Session attachments field or upload the image beforehand to obtain a URL. Instead, it sends the image as an MCP image block, based on the LLM documentation.

The RPC logs confirm that the request contained the full image data. Validation of the image data in the logs produced the following results:

Item Result
MIME type image/png
Image dimensions 1280 × 720
Decoded size 1,867,166 bytes
Base64 character count 2,489,556
Strict base64 decoding Passed
PNG chunk checksum and compressed data checks Passed

Therefore, at least at the point where this RPC request was logged, the image data was complete and could be decoded correctly.

3. Actual Response: The Request Succeeded, but the Model Said It Could Not See the Image

The RPC returned res ok llm.complete, with no APP_PROVIDER_ERROR or parameter error.

The key response fields are shown below. Only the opening of content.text is included, translated from Chinese into English:

{
  "role": "assistant",
  "content": {
    "type": "text",
    "text": "At the moment, I **cannot directly see** the image attachment you uploaded."
  },
  "model": "google/gemini-3-flash-preview",
  "stopReason": "endTurn",
  "usage": {
    "inputTokens": 9,
    "outputTokens": 151,
    "totalTokens": 160
  },
  "_meta": {
    "provider": "openrouter",
    "latencyMs": 2984,
    "quotaConsumed": 0.3948275862068965,
    "appSessionUuid": "aps_xjusbrpjg5biqhggfprkdm"
  }
}

The rest of the response suggested resending the image using the upload button in the chat interface and checking for delays or format issues. It did not describe the image’s contents.

Two aspects of this response stand out:

  1. The model explicitly stated that it could not directly see the uploaded attachment.
  2. usage.inputTokens was only 9, which appears inconsistent with a request containing the full image and suggests that only the text portion may have been processed.

The model’s natural-language response and token counts alone cannot establish where the image was lost. However, together with the complete outbound RPC request, the observed behavior suggests that the image input was accepted but did not actually participate in model inference.

4. Comparison Test: URL-Based Image Input Works in Agent Session

In the Session feature of the same test application, I used a different image input path:

  1. Upload the image and obtain its URL.
  2. Call sess.run(), passing the URL through attachments.
  3. Check whether the model can recognize the image’s contents.

The corresponding request structure is shown below, with the Chinese prompt translated into English:

const stream = sess.run({
  content: "Please describe the contents of this image.",
  attachments: [
    {
      type: "image/png",
      url: "<image URL obtained after upload>"
    }
  ]
});

This Session path has been verified to work: the model can read and recognize the image’s contents correctly.

The test results for the two paths are compared below:

Item llm.complete Agent Session
Image input location messages[].content attachments[]
Data format used in this test Raw base64 in an MCP image block Uploaded image URL
Complete image example in the documentation No; only {type:'image', ...} is shown Yes; examples for both URL and base64 inputs
Observed result Request succeeded, but the model said it could not see the image Image was recognized correctly

This comparison shows that the Session URL-based image input path works. It does not establish that the two paths use the same internal message conversion and model routing, so the llm.complete implementation still needs to be investigated separately.

5. Expected Behavior

If the MCP image block structure above is supported:

  • The image should be converted into multimodal input that the upstream model can process.
  • The model should be able to answer based on the image.

If this structure is not yet supported, or if there are restrictions on input formats, model capabilities, or other factors:

  • A clear parameter or capability error should be returned.
  • The call should not return successfully as an ordinary text completion when the image has not participated in inference.

6. Questions for the Team

  1. Does llm.complete currently support the following complete image block structure?

    {
      type: "image",
      data: "<raw base64>",
      mimeType: "image/png"
    }
    
  2. Is placing text and images in separate, consecutive user messages a supported usage pattern?

  3. If the usage above is correct, could you inspect the server-side message conversion (_assemble_lc_messages, as mentioned in the documentation) and subsequent provider adaptation to confirm that the image content is passed to the upstream model in full?

  4. Could you add a directly runnable image input example for llm.complete, similar to the Agent Session documentation, and clarify support and limitations for base64, data URIs, and URLs?

At this point, I suspect either an implementation issue in the llm.complete image input path or a discrepancy between the documented and actual support. I would like to confirm the correct protocol first, then determine whether the application-side implementation needs to be adjusted.

Hi @HappyLight :waving_hand:

Thank you for this outstanding report — the RPC-level validation, the token-count observation, and the side-by-side comparison with Agent Session made this a joy to investigate. You were spot on. :bullseye:

What we found :magnifying_glass_tilted_left:

You uncovered a real bug. The llm.complete message assembler was text-only: MCP {type:'image', ...} blocks were silently dropped before the request ever reached the model. That’s exactly why you saw a “successful” response, inputTokens: 9, and a model that couldn’t see your (perfectly valid) PNG. The same issue affected llm.stream and the plugin-side agent/complete. The documentation was ahead of the implementation here — sorry about the confusion that caused. :person_bowing:

What’s fixed — host 1.1.0-beta.169 :hammer_and_wrench:

Rolling out this Friday:

  • :framed_picture: Image blocks now work end-to-end on llm.complete, llm.stream, and agent/complete. Supported shapes:
    • { type: "image", data: "<raw base64>", mimeType: "image/png" } (exactly what you sent — no app-side changes needed!)
    • data as a full data: URI (mimeType optional)
    • { type: "image", url: "https://…" } (public HTTPS)
  • :speech_balloon: Yes, your usage pattern is supported: text and images may share one content array or sit in separate consecutive user messages — both work.
  • :vertical_traffic_light: No more silent failures: if the resolved model isn’t vision-capable, the call now fails fast with APP_MODEL_NOT_VISION_CAPABLE (use modelPreferences.hints to pick a vision model). Malformed or unsupported blocks return APP_INVALID_REQUEST. Images are never quietly dropped again.
  • :straight_ruler: Limits match Agent Session attachments: image/jpeg|png|gif|webp|bmp|svg+xml, ≤ 20 MB per image.
  • :books: Docs updated: the LLM Host API reference now documents the exact image block fields, limits, and errors, and the App-Side LLM & Agent guide has a new runnable image example — same level of detail as the Agent Session attachments section you referenced.
  • :test_tube: The official anna-app-llm-demo example has been updated with an image input so you can compare against a known-good request.

One note: the plugin-side sampling/createMessage remains text-only for now and rejects image blocks with a clear error — use agent/complete or session attachments on that surface.

Your original request body should work as-is once the update lands — we’d love to hear how it goes. Thanks again for the meticulous report; reports like this make the platform better for every developer. :yellow_heart: