Skip to content

Commit 7705f77

Browse files
authored
fix(embed): send images when an OpenAI-compatible row declares them (#4389)
An embedding row with spec.input image and no image_format or image_field was treated as text-only on every vendor except generic and Jina. Default that case to the vLLM messages format. An explicit object format without a field stays refused. Fixes #4343
1 parent 7ba8e2e commit 7705f77

3 files changed

Lines changed: 45 additions & 3 deletions

File tree

‎internal/models/embedding/image_wire_test.go‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,8 @@ func TestImageEmbeddingNeedsBothTheModelAndTheEndpoint(t *testing.T) {
154154
{"text model on the OpenAI shape", "openai", "text-embedding-3-small", nil, false},
155155
{"generic endpoint, image input not declared", "generic", "my-clip", nil, false},
156156
{"declared on a generic (vLLM) endpoint", "generic", "my-clip", declare, true},
157+
{"declared on the OpenAI provider", "openai", "my-clip", declare, true},
158+
{"declared on LiteLLM", "litellm", "my-clip", declare, true},
157159
{
158160
"declared, but the row switched to an object format without a field", "generic", "my-clip",
159161
&types.ModelSpecOverride{
@@ -188,6 +190,35 @@ func TestImageEmbeddingNeedsBothTheModelAndTheEndpoint(t *testing.T) {
188190
// image both pass the chat template and the prompt ends in <|endoftext|>,
189191
// the token its last-token pooling reads. A plain `input` string skips the
190192
// template and lands in a different space from the images.
193+
// An OpenAI-compatible gateway has no image field of its own. Declaring
194+
// image input, and nothing else, must still send the picture: vLLM takes it
195+
// as a messages conversation. The text request stays on input.
196+
func TestDeclaredImageInputOnOpenAIGatewayUsesMessages(t *testing.T) {
197+
up := newUpstream(t)
198+
declare := &types.ModelSpecOverride{Input: []string{"text", "image"}}
199+
e := imageEmbedder(t, up, "openai", "WeMM-Embedding-2B", "/v1", declare)
200+
ie, ok := AsImageEmbedder(e)
201+
require.True(t, ok)
202+
203+
_, err := e.BatchEmbed(context.Background(), []string{"bridge"})
204+
require.NoError(t, err)
205+
images, err := ie.BatchEmbedImages(context.Background(), pngs(1))
206+
require.NoError(t, err)
207+
assert.Equal(t, [][]float32{{1}}, images)
208+
209+
require.Len(t, up.requests, 2)
210+
assert.Equal(t, map[string]any{
211+
"model": "WeMM-Embedding-2B", "encoding_format": "float",
212+
"input": []any{"bridge"},
213+
}, up.requests[0].body)
214+
assert.Equal(t, map[string]any{
215+
"model": "WeMM-Embedding-2B", "encoding_format": "float",
216+
"messages": []any{map[string]any{"role": "user", "content": []any{
217+
map[string]any{"type": "image_url", "image_url": map[string]any{"url": pngs(1)[0].DataURI()}},
218+
}}},
219+
}, up.requests[1].body)
220+
}
221+
191222
func TestSelfHostedQwen3VLEmbeddingSendsTextAndImagesThroughTheTemplate(t *testing.T) {
192223
up := newUpstream(t)
193224
e := imageEmbedder(t, up, "generic", "Qwen/Qwen3-VL-Embedding-8B", "/v1", nil)

‎internal/models/runtime/resolve.go‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -492,6 +492,17 @@ func resolveEmbeddings(
492492
"catalog: unknown embedding image_format %q on %s/%s (expected %q or %q)",
493493
settings.ImageFormat, vendor.ID, spec.ID, api.EmbeddingImageObject, api.EmbeddingImageMessages)
494494
}
495+
// A row that declares image input on the OpenAI embedding shape still
496+
// needs a wire format: that shape has none of its own, and only Jina
497+
// names one. vLLM, the usual server behind these gateways, takes one
498+
// chat conversation in messages. Jina and SGLang stay explicit
499+
// (image_format=object plus image_field). An object format without a
500+
// field is left alone, so a half-filled row is refused instead of resent
501+
// as messages. Text stays on input unless the row sets text_as_messages.
502+
if protocol == api.EmbeddingOpenAI && spec.AcceptsImages() &&
503+
settings.ImageFormat == "" && settings.ImageField == "" {
504+
settings.ImageFormat = api.EmbeddingImageMessages
505+
}
495506

496507
out := &Resolved{
497508
Vendor: configcopy.Clone(vendor.Definition),

‎website-docs/03-features/06-models.md‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,7 @@
8989
| 需要附带厂商私有参数,如阿里云联网搜索 | `{"extra_body": {"enable_search": true}}` |
9090
| 多轮对话回传思考内容时报错 | `{"replay_reasoning_content": false}` |
9191
| 自建向量服务一次请求只接受 16 条 | `{"max_batch_size": 16}` |
92-
| vLLM 部署的多模态向量模型(VLM2Vec、GME 等) | 打开「图片输入」即可,自定义厂商默认按 vLLM 的 `messages` 格式发图片;模板需要指令时加 `{"image_prompt": "Represent the given image."}`;文本也必须经过对话模板才和图片同空间时加 `{"text_as_messages": true}` |
92+
| vLLM 部署的多模态向量模型(VLM2Vec、GME 等) | 打开「图片输入」即可。未填 `image_format` 和 `image_field` 时,OpenAI 兼容协议按 vLLM 的 `messages` 格式发图片。模板需要指令时加 `{"image_prompt": "Represent the given image."}`;文本也必须经过对话模板才和图片同空间时加 `{"text_as_messages": true}` |
9393
| vLLM 部署的 Qwen3-VL-Embedding | 无需填写:自定义厂商下模型名含 `qwen3-vl-embedding` 时已内置图片输入与 `{"text_as_messages": true, "extra_body": {"add_special_tokens": true}}`。文本走普通 `input` 会绕过对话模板,与图片不在同一空间;不加 `add_special_tokens` 时末尾缺少模型取向量用的 `<\|endoftext\|>`,与[模型卡](https://huggingface.co/Qwen/Qwen3-VL-Embedding-8B)结果不符。已用该模型建立的索引需重建向量 |
9494
| SGLang 部署的多模态向量模型 | 打开「图片输入」,并填 `{"image_format": "object", "image_field": "image"}` |
9595
| 向量或重排服务响应慢,需要更长的超时 | `{"request_timeout_seconds": 120}` |
@@ -235,8 +235,8 @@ WeMM 的模板已经包含所需特殊 token,保留 vLLM 的 `add_special_toke
235235
| `accepts_truncate_prompt_tokens` | bool | false | 服务是否支持 vLLM 的 `truncate_prompt_tokens` |
236236
| `request_timeout_seconds` | int | 60 | 单次请求超时(秒) |
237237
| `extra_body` | object | 空 | 附加到每次请求的字段 |
238-
| `image_field` | string | 空 | 仅 `openai-embeddings`:`input` 里承载图片的对象键名(Jina、SGLang 为 `image`)。留空且 `image_format` 不是 `messages` 时表示该端点不收图片 |
239-
| `image_format` | string | 空(自定义厂商为 `messages`) | 仅 `openai-embeddings`:图片的发送方式。`object` 把 `{image_field: 图片}` 放进 `input`;`messages` 把图片作为一轮对话放进 `messages`(vLLM 的多模态向量接口),每张图一个请求。留空时有 `image_field` 即按 `object` 处理 |
238+
| `image_field` | string | 空 | 仅 `openai-embeddings`:`input` 里承载图片的对象键名(Jina、SGLang 为 `image`)。模型未声明图片输入、留空、且 `image_format` 不是 `messages` 时,端点不收图片 |
239+
| `image_format` | string | 空 | 仅 `openai-embeddings`:图片的发送方式。`object` 把 `{image_field: 图片}` 放进 `input`;`messages` 把图片作为一轮对话放进 `messages`(vLLM 的多模态向量接口)。模型声明了图片输入、本字段和 `image_field` 都留空时,按 `messages` 发送。只填了 `image_field` 时按 `object` 处理。写成 `object` 却没有 `image_field` 时仍不发图片 |
240240
| `image_prompt` | string | 空 | 仅 `messages` 格式:随图片一起发送的文字,部分模型的对话模板需要(如 VLM2Vec 的 `Represent the given image.`) |
241241
| `text_as_messages` | bool | false | 仅 `openai-embeddings`:文本也按 `messages` 格式发送,每条一个请求。适用于必须经过对话模板、文本和图片才在同一向量空间的模型(自定义厂商下的 Qwen3-VL-Embedding 已内置);已有索引的模型改动后需要重建向量 |
242242
| `max_image_batch_size` | int | 1 | 单次请求的最大图片数,超出自动分批 |

0 commit comments

Comments
 (0)