Skip to content

feat: vision and multimodal input across all providers - #18

Merged
ardada2468 merged 3 commits into
mainfrom
feat/vision-multimodal
Aug 22, 2026
Merged

ardada2468 merged 3 commits into
mainfrom
feat/vision-multimodal

Conversation

@ardada2468

Copy link
Copy Markdown
Owner

What

ModelCapabilities.supportsVision was reported true by all three providers, but ChatMessage.content was a plain string and every converter passed it straight through — there was no way to send an image. This makes the flag true.

  • ChatMessage.content is now string | ContentPart[]. Keeping string in the union is what keeps the blast radius small: BaseLM.generate(), generateStructured(), parseJsonResponse and all three modules build string prompts and compile untouched.
  • ContentPart is text or image; an image source is a URL (https:// or data:) or base64 plus a media type. Helpers: imagePart(), textPart(), contentToText(), toContentParts(), hasImageContent(), imageToUrl(), imageMediaType(), normalizeImageSource().
  • Signature image inputs: @ImageField({ … }), or photo: image in a string signature. buildPromptContent() emits content parts when any input is an image and a plain string otherwise — byte for byte what buildPrompt() produces. buildPrompt() still returns a string, rendering an image as an [image: image/png] placeholder.
  • supportsVision is per model: false for gpt-3.5/gpt-4-0*/gpt-4-32k/o1-mini/o1-preview/o3-mini, for claude-3-5-haiku and older Claude, and for Gemini embedding models.

Per-provider mapping

Provider Inline bytes Remote URL
OpenAI image_url with a data: URI image_url.url, plus detail
Anthropic image block, source.type: "base64" image block, source.type: "url"
Gemini inlineData with mimeType fileData.fileUri, Files API / GCS only

OpenAI's conversion is role-aware: ChatCompletionMessageParam is a discriminated union where only user accepts image parts, so system and assistant content is flattened to text. Anthropic's system parameter and Gemini's systemInstruction are flattened the same way.

Gemini refuses an arbitrary web URL with a clear LMError instead of sending it on — fileData only dereferences Files API and Cloud Storage URIs.

The Anthropic risk

toAnthropicMessages merged consecutive same-role turns by string concatenation, guarded by typeof previous.content === 'string'. With array content that guard silently stops merging, and the Messages API requires strict alternation — so a text turn followed by an image turn would have been sent as two adjacent user messages and rejected. The merge now concatenates block arrays, folding two adjoining text blocks into one so the blank line between turns survives. The existing test asserting the string-merged shape was updated with a comment explaining why, and a new test covers the text-then-image adjacency directly.

Verification

npm run build, lint, typecheck, format:check, test (206 pass) and verify:packaging all green. A throwaway consumer script (deleted before commit) imported the built packages through the workspace symlinks and asserted each converter's SDK shape for text-only, mixed, and remote-URL messages, plus the alternation case — the text-only path is byte-identical to before.

Docs: new section 19 #vision in site/docs.html (verified rendering locally, TOC entry included) and an Images section in the README.

🤖 Generated with Claude Code

ardada2468 and others added 3 commits August 22, 2026 13:35
`ChatMessage.content` was a plain `string` and each converter passed it
straight through, so `supportsVision: true` was a claim the library could
not honour. Content is now `string | ContentPart[]`, where a part is text
or an image carried as a URL, a data URI, or base64 plus a media type.
Plain strings stay valid, so text-only code is untouched.

Signature inputs can be declared images with `@ImageField` (or the
`image` type in a string signature), and `buildPromptContent()` renders
such a signature as parts, returning a string when every input is text.

Per provider: OpenAI `image_url` parts, role-aware because only the user
variant of its message union accepts them; Anthropic `image` blocks, with
same-role merging moved onto block arrays so a text turn beside an image
turn still satisfies strict alternation; Gemini `inlineData`, refusing a
URL `fileData` cannot dereference rather than earning a 400.

`supportsVision` is now reported per model instead of hardcoded.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
# Conflicts:
#	README.md
#	packages/anthropic/src/anthropic-lm.test.ts
#	packages/anthropic/src/anthropic-lm.ts
#	packages/core/src/index.ts
#	packages/core/src/test-utils.ts
#	packages/core/src/types/language-model.ts
#	packages/core/src/utils/parsing.ts
#	packages/gemini/src/gemini-lm.test.ts
#	packages/gemini/src/gemini-lm.ts
#	packages/openai/src/openai-lm.test.ts
#	packages/openai/src/openai-lm.ts
#	site/docs.html
@ardada2468
ardada2468 merged commit a7be8f4 into main Aug 22, 2026
7 checks passed
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