This is an automated email from the ASF dual-hosted git repository.

tballison pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/tika.git


The following commit(s) were added to refs/heads/main by this push:
     new 61589bed69 TIKA-4896: requestParameters and imageInput on 
openai-embedding-engin… (#3183)
61589bed69 is described below

commit 61589bed69f8f816f149d82f886e76c54160cece
Author: Tim Allison <[email protected]>
AuthorDate: Wed Sep 16 13:32:27 2026 -0400

    TIKA-4896: requestParameters and imageInput on openai-embedding-engin… 
(#3183)
---
 CHANGES.txt                                        |  3 +
 .../ROOT/pages/configuration/inference.adoc        | 89 +++++++++++++++++++++-
 .../apache/tika/cli/TikaCLITextVectorsTest.java    |  6 +-
 .../tika/inference/OpenAIEmbeddingEngine.java      | 64 +++++++++++++++-
 .../tika/inference/OpenAIEmbeddingEngineTest.java  | 52 +++++++++++++
 5 files changed, 210 insertions(+), 4 deletions(-)

diff --git a/CHANGES.txt b/CHANGES.txt
index ff34ddd949..4a41f7d0a1 100644
--- a/CHANGES.txt
+++ b/CHANGES.txt
@@ -1,5 +1,8 @@
 Release 4.1.0 - unreleased
 
+   * openai-embedding-engine gains "requestParameters" to enable custom
+     parameters per vendor (TIKA-4896).
+     
    * New catalog presets, inert until the config names them. (TIKA-4856).
    
    * The inference dispatcher no longer copies the top-level document's own
diff --git a/docs/modules/ROOT/pages/configuration/inference.adoc 
b/docs/modules/ROOT/pages/configuration/inference.adoc
index f559595fe7..8a958813a6 100644
--- a/docs/modules/ROOT/pages/configuration/inference.adoc
+++ b/docs/modules/ROOT/pages/configuration/inference.adoc
@@ -49,7 +49,94 @@ different models. Secrets and paths live here and only here.
 
 `openai-embedding-engine` posts to `/v1/embeddings` in the OpenAI shape and 
sends up to
 `maxBatchSize` images per request. Options: `baseUrl`, `model`, `apiKey`, 
`timeoutMillis`
-(120000), `maxBatchSize` (32), `embeddingsPath`, `apiKeyHeaderName`, 
`apiKeyPrefix`.
+(120000), `maxBatchSize` (32), `embeddingsPath`, `apiKeyHeaderName`, 
`apiKeyPrefix`,
+`requestParameters`, `imageInput`.
+
+=== Vendor keys: `requestParameters`
+
+Most hosted and self-hosted embedding services speak the OpenAI shape and add 
their own keys
+to it. `requestParameters` is a map copied verbatim into every request body, 
beside the
+engine's `model` and `input`; the engine neither knows nor checks the keys, so 
use the names
+your vendor documents. Three keys recur under different names: a Matryoshka 
dimension
+(`dimensions`, `output_dimension`), a query-versus-document hint (`task`, 
`input_type`,
+`task_type`; use the document/passage value, since Tika indexes), and 
normalize/truncate
+flags. Two rules: `model` and `input` belong to the engine and are refused; so 
is any key that
+changes the response from float vectors by index (`encoding_format`, 
`embedding_type`,
+`output_type`, `output_dtype`, `return_multivector`), since that is what the 
engine reads back.
+
+Jina `jina-embeddings-v5-omni` embeds text, images and rendered pages in one 
space; `task`
+selects the indexing adapter and `dimensions` the vector size. `PAGES` sends 
each page's
+rendering as a PNG, never the PDF itself, and the PDF parser renders only when 
its
+`inference.input` says so:
+
+[source,json]
+----
+{
+  "engines": {
+    "jina": { "openai-embedding-engine": {
+      "baseUrl": "https://api.jina.ai";, "apiKey": "...",
+      "model": "jina-embeddings-v5-omni-small", "maxBatchSize": 16,
+      "requestParameters": { "task": "retrieval.passage", "dimensions": 1024 } 
} }
+  },
+  "inference": [
+    { "id": "pictures", "engine": "jina", "input": "IMAGES", "tasks": 
["embed"], "maxBytes": 5000000 },
+    { "id": "pages",    "engine": "jina", "input": "PAGES",  "tasks": 
["embed"], "maxBytes": 5000000 },
+    { "id": "text",     "engine": "jina", "input": "TEXT",   "tasks": 
["embed"],
+      "chunker": { "markdown-chunker": { "maxChunkChars": 1500, 
"overlapChars": 200 } } }
+  ],
+  "parsers": [
+    { "default-parser": {} },
+    { "pdf-parser": { "inference": { "input": ["TEXT", "PAGES"] },
+                      "rendering": { "dpi": 96, "imageType": "RGB" } } }
+  ]
+}
+----
+
+Azure OpenAI puts the key in an `api-key` header with no prefix and the 
deployment and API
+version in the path; `dimensions` truncates `text-embedding-3-large`. Plain 
OpenAI is the
+same engine with the defaults and `"baseUrl": "https://api.openai.com"`:
+
+[source,json]
+----
+{
+  "engines": {
+    "azure": { "openai-embedding-engine": {
+      "baseUrl": "https://my-resource.openai.azure.com";,
+      "embeddingsPath": 
"/openai/deployments/text-embedding-3-large/embeddings?api-version=2024-10-21",
+      "apiKeyHeaderName": "api-key", "apiKeyPrefix": "", "apiKey": "...",
+      "requestParameters": { "dimensions": 1024 } } }
+  }
+}
+----
+
+The OpenAI shape has no image input, so services differ on how an image goes 
into `input`:
+`imageInput` is `object` (the default: `{"image": "data:..."}`, Jina's form) 
or `data-uri` (a
+bare `data:...` string, the form LiteLLM takes).
+
+For a service whose request shape is not OpenAI's (Google's `embedContent`, 
Amazon Bedrock's
+`InvokeModel`, Cohere's `/v2/embed`), put a gateway that speaks the OpenAI 
shape in front of it
+and point this engine at the gateway. LiteLLM routes `gemini-embedding-2` this 
way, text and
+images alike, on the Gemini API path (its Vertex path fuses every input into 
one vector,
+which this engine refuses because it expects one per input), and Amazon Nova 2 
on Bedrock
+for text and images, one input per request; the gateway holds the cloud 
credentials, and
+provider keys ride `requestParameters`. Audio and video on those services go 
through
+asynchronous, object-store-backed jobs that an embeddings call cannot use.
+
+[source,json]
+----
+{
+  "engines": {
+    "gemini": { "openai-embedding-engine": {
+      "baseUrl": "http://litellm:4000";, "apiKey": "...",
+      "model": "gemini-embedding-2-preview", "imageInput": "data-uri", 
"maxBatchSize": 6 } },
+    "nova": { "openai-embedding-engine": {
+      "baseUrl": "http://litellm:4000";, "apiKey": "...",
+      "model": "bedrock/amazon.nova-2-multimodal-embeddings-v1:0",
+      "imageInput": "data-uri", "maxBatchSize": 1,
+      "requestParameters": { "dimensions": 1024, "embedding_purpose": 
"GENERIC_INDEX" } } }
+  }
+}
+----
 
 A text recognizer is an engine too: `"tesseract": { "tesseract-ocr-parser": { 
"language": "eng" } }`
 here, and `{ "engine": "tesseract" }` in
diff --git 
a/tika-app/src/test/java/org/apache/tika/cli/TikaCLITextVectorsTest.java 
b/tika-app/src/test/java/org/apache/tika/cli/TikaCLITextVectorsTest.java
index 899e7ac1c4..1676bbc16d 100644
--- a/tika-app/src/test/java/org/apache/tika/cli/TikaCLITextVectorsTest.java
+++ b/tika-app/src/test/java/org/apache/tika/cli/TikaCLITextVectorsTest.java
@@ -67,7 +67,8 @@ public class TikaCLITextVectorsTest {
             Files.writeString(config, """
                     {
                       "engines": {
-                        "embedder": { "openai-embedding-engine": { "baseUrl": 
"BASE_URL", "model": "bge" } }
+                        "embedder": { "openai-embedding-engine": { "baseUrl": 
"BASE_URL", "model": "bge",
+                          "requestParameters": { "task": "retrieval.passage", 
"dimensions": 2, "normalized": true } } }
                       },
                       "inference": [
                         { "id": "text-vectors", "engine": "embedder", "input": 
"TEXT", "tasks": ["embed"],
@@ -84,6 +85,9 @@ public class TikaCLITextVectorsTest {
             JsonNode request = new 
ObjectMapper().readTree(server.takeRequest().body());
             // the zip's own markdown content is two headings, so two chunks; 
one per note
             assertEquals(4, request.get("input").size(), "chunks of all three 
documents in one request");
+            assertEquals("retrieval.passage", request.get("task").asText(), 
"requestParameters ride the body");
+            assertEquals(2, request.get("dimensions").asInt());
+            assertTrue(request.get("normalized").asBoolean());
             assertEquals("# alpha.txt", request.get("input").get(0).asText());
             assertEquals("alpha is the first note", 
request.get("input").get(2).asText());
 
diff --git 
a/tika-parsers/tika-parsers-ml/tika-inference/src/main/java/org/apache/tika/inference/OpenAIEmbeddingEngine.java
 
b/tika-parsers/tika-parsers-ml/tika-inference/src/main/java/org/apache/tika/inference/OpenAIEmbeddingEngine.java
index a6d431d3f9..d64485e4e2 100644
--- 
a/tika-parsers/tika-parsers-ml/tika-inference/src/main/java/org/apache/tika/inference/OpenAIEmbeddingEngine.java
+++ 
b/tika-parsers/tika-parsers-ml/tika-inference/src/main/java/org/apache/tika/inference/OpenAIEmbeddingEngine.java
@@ -20,8 +20,10 @@ import java.io.IOException;
 import java.util.ArrayList;
 import java.util.Base64;
 import java.util.HashMap;
+import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Map;
+import java.util.Set;
 
 import com.fasterxml.jackson.databind.JsonNode;
 import com.fasterxml.jackson.databind.ObjectMapper;
@@ -41,6 +43,12 @@ import org.apache.tika.utils.StringUtils;
  * An OpenAI-compatible embeddings endpoint ({@code POST /v1/embeddings}), 
named in
  * {@code "engines"}. Sends up to {@code maxBatchSize} images per request as
  * {@code {"image": "data:<mime>;base64,..."}} inputs and reads the vectors 
back by index.
+ * {@code requestParameters} ride every request body verbatim, for the keys a 
vendor adds to
+ * the shape (Jina's {@code task}, a Matryoshka {@code dimensions}); the 
engine reads float
+ * vectors back, so a key that changes the response shape is refused at load. 
The OpenAI
+ * shape has no image input, so vendors differ: {@code imageInput} is {@code 
"object"} for
+ * the {@code {"image": ...}} form (Jina) or {@code "data-uri"} for a bare 
data URI in
+ * {@code input} (LiteLLM and other gateways).
  */
 @TikaComponent(name = "openai-embedding-engine", spi = false)
 public class OpenAIEmbeddingEngine implements EmbeddingEngine, Initializable {
@@ -55,6 +63,16 @@ public class OpenAIEmbeddingEngine implements 
EmbeddingEngine, Initializable {
     private String embeddingsPath = "/v1/embeddings";
     private String apiKeyHeaderName = "Authorization";
     private String apiKeyPrefix = "Bearer ";
+    private Map<String, Object> requestParameters = new LinkedHashMap<>();
+    private String imageInput = IMAGE_INPUT_OBJECT;
+
+    public static final String IMAGE_INPUT_OBJECT = "object";
+    public static final String IMAGE_INPUT_DATA_URI = "data-uri";
+
+    private static final Set<String> ENGINE_OWNED = Set.of("model", "input");
+    /** Keys that would make the response something other than float vectors 
by index. */
+    private static final Set<String> RESPONSE_SHAPING = 
Set.of("encoding_format",
+            "embedding_type", "output_type", "output_dtype", 
"return_multivector");
 
     private TikaHttpClient httpClient;
 
@@ -66,6 +84,21 @@ public class OpenAIEmbeddingEngine implements 
EmbeddingEngine, Initializable {
         if (maxBatchSize < 1) {
             throw new TikaConfigException("maxBatchSize must be at least 1");
         }
+        if (!IMAGE_INPUT_OBJECT.equals(imageInput) && 
!IMAGE_INPUT_DATA_URI.equals(imageInput)) {
+            throw new TikaConfigException("imageInput must be \"" + 
IMAGE_INPUT_OBJECT + "\" or \""
+                    + IMAGE_INPUT_DATA_URI + "\", not \"" + imageInput + "\"");
+        }
+        for (String key : requestParameters.keySet()) {
+            if (ENGINE_OWNED.contains(key)) {
+                throw new TikaConfigException("requestParameters may not set 
\"" + key
+                        + "\"; the engine writes it");
+            }
+            if (RESPONSE_SHAPING.contains(key)) {
+                throw new TikaConfigException("requestParameters may not set 
\"" + key
+                        + "\": the engine reads float vectors back, so the 
response shape "
+                        + "must stay the default");
+            }
+        }
         httpClient = TikaHttpClient.build(30);
     }
 
@@ -75,8 +108,13 @@ public class OpenAIEmbeddingEngine implements 
EmbeddingEngine, Initializable {
         ObjectNode root = request();
         ArrayNode input = root.putArray("input");
         for (int i = 0; i < images.size(); i++) {
-            input.addObject().put("image", "data:" + mimeTypes.get(i) + 
";base64,"
-                    + Base64.getEncoder().encodeToString(images.get(i)));
+            String dataUri = "data:" + mimeTypes.get(i) + ";base64,"
+                    + Base64.getEncoder().encodeToString(images.get(i));
+            if (IMAGE_INPUT_DATA_URI.equals(imageInput)) {
+                input.add(dataUri);
+            } else {
+                input.addObject().put("image", dataUri);
+            }
         }
         return post(root, images.size(), context);
     }
@@ -94,6 +132,9 @@ public class OpenAIEmbeddingEngine implements 
EmbeddingEngine, Initializable {
 
     private ObjectNode request() {
         ObjectNode root = MAPPER.createObjectNode();
+        for (Map.Entry<String, Object> e : requestParameters.entrySet()) {
+            root.set(e.getKey(), MAPPER.valueToTree(e.getValue()));
+        }
         if (!StringUtils.isBlank(model)) {
             root.put("model", model);
         }
@@ -220,6 +261,25 @@ public class OpenAIEmbeddingEngine implements 
EmbeddingEngine, Initializable {
         this.apiKeyHeaderName = apiKeyHeaderName;
     }
 
+    /** How an image goes into {@code input}: {@code "object"} or {@code 
"data-uri"}; see the class note. */
+    public String getImageInput() {
+        return imageInput;
+    }
+
+    public void setImageInput(String imageInput) {
+        this.imageInput = imageInput;
+    }
+
+    /** Extra keys for every request body, as the vendor names them; see the 
class note. */
+    public Map<String, Object> getRequestParameters() {
+        return requestParameters;
+    }
+
+    public void setRequestParameters(Map<String, Object> requestParameters) {
+        this.requestParameters = requestParameters == null ? new 
LinkedHashMap<>()
+                : new LinkedHashMap<>(requestParameters);
+    }
+
     public String getApiKeyPrefix() {
         return apiKeyPrefix;
     }
diff --git 
a/tika-parsers/tika-parsers-ml/tika-inference/src/test/java/org/apache/tika/inference/OpenAIEmbeddingEngineTest.java
 
b/tika-parsers/tika-parsers-ml/tika-inference/src/test/java/org/apache/tika/inference/OpenAIEmbeddingEngineTest.java
index fdb658c5f0..9c1fcc7b0a 100644
--- 
a/tika-parsers/tika-parsers-ml/tika-inference/src/test/java/org/apache/tika/inference/OpenAIEmbeddingEngineTest.java
+++ 
b/tika-parsers/tika-parsers-ml/tika-inference/src/test/java/org/apache/tika/inference/OpenAIEmbeddingEngineTest.java
@@ -18,6 +18,7 @@ package org.apache.tika.inference;
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
 
 import java.nio.charset.StandardCharsets;
 import java.util.List;
@@ -123,4 +124,55 @@ public class OpenAIEmbeddingEngineTest {
         bad.setMaxBatchSize(0);
         assertThrows(org.apache.tika.exception.TikaConfigException.class, 
bad::initialize);
     }
+
+    /** Vendor keys ride every body verbatim, beside the engine's own model 
and input. */
+    @Test
+    public void testRequestParametersRideEveryRequest() throws Exception {
+        engine.setRequestParameters(new 
java.util.LinkedHashMap<>(java.util.Map.of(
+                "task", "retrieval.passage", "dimensions", 1024, "normalized", 
true)));
+        engine.initialize();
+        ObjectMapper mapper = new ObjectMapper();
+        for (int i = 0; i < 2; i++) {
+            server.enqueue(new TikaTestHttpServer.MockResponse(200,
+                    "{\"data\":[{\"index\":0,\"embedding\":[0.5]}]}"));
+        }
+        engine.embedTexts(List.of("alpha"), new ParseContext());
+        engine.embedImages(List.of(bytes("png")), List.of("image/png"), new 
ParseContext());
+        for (int i = 0; i < 2; i++) {
+            JsonNode request = mapper.readTree(server.takeRequest().body());
+            assertEquals("retrieval.passage", request.get("task").asText());
+            assertEquals(1024, request.get("dimensions").asInt());
+            assertTrue(request.get("normalized").asBoolean());
+            assertEquals("clip", request.get("model").asText(), "the engine 
still writes model");
+            assertEquals(1, request.get("input").size());
+        }
+    }
+
+    /** A gateway that takes bare data URIs in input, as LiteLLM does, gets 
strings, not objects. */
+    @Test
+    public void testImageInputAsDataUriString() throws Exception {
+        engine.setImageInput(OpenAIEmbeddingEngine.IMAGE_INPUT_DATA_URI);
+        engine.initialize();
+        server.enqueue(new TikaTestHttpServer.MockResponse(200,
+                "{\"data\":[{\"index\":0,\"embedding\":[0.5]}]}"));
+        engine.embedImages(List.of(bytes("png")), List.of("image/png"), new 
ParseContext());
+        JsonNode input = new 
ObjectMapper().readTree(server.takeRequest().body()).get("input");
+        assertTrue(input.get(0).isTextual(), "a bare string, not an object");
+        assertTrue(input.get(0).asText().startsWith("data:image/png;base64,"));
+
+        OpenAIEmbeddingEngine bad = new OpenAIEmbeddingEngine();
+        bad.setImageInput("base64");
+        assertThrows(org.apache.tika.exception.TikaConfigException.class, 
bad::initialize);
+    }
+
+    /** The engine owns model and input, and reads float vectors: those keys 
are refused at load. */
+    @Test
+    public void testRequestParametersReservedKeysAreRefused() {
+        for (String key : List.of("model", "input", "encoding_format", 
"embedding_type",
+                "output_type")) {
+            OpenAIEmbeddingEngine bad = new OpenAIEmbeddingEngine();
+            bad.setRequestParameters(java.util.Map.of(key, "x"));
+            assertThrows(org.apache.tika.exception.TikaConfigException.class, 
bad::initialize, key);
+        }
+    }
 }

Reply via email to