This is an automated email from the ASF dual-hosted git repository. wenjin272 pushed a commit to branch main in repository https://gitbox.apache.org/repos/asf/flink-agents.git
commit e8f79d51f6e3eef205e3ff073da1124058c59c04 Author: purshotam shah <[email protected]> AuthorDate: Fri Sep 18 10:50:20 2026 -0700 [integrations][java] Add OpenAI embedding model (#1103) Adds integrations/embedding-models/openai with OpenAIEmbeddingModelConnection and OpenAIEmbeddingModelSetup on com.openai:openai-java, mirroring the Python connection and setup arguments (api_key, base_url, request_timeout, max_retries, organization, project; connection, model, encoding_format, dimensions, user, additional_kwargs). Batches are one request placed by response index with size, range and consistency checks; base64 responses are decoded; token usage is reported through embedWithUsage. Arguments are validated once at setup construction and per call by the same package-private parsers; additional_kwargs may not repeat a typed request field. Registers ResourceName.EmbeddingModel.OPENAI_*, the Python mirror and the `openai` YAML alias in both languages, dist and ide-support dependencies. EmbeddingIntegrationTest gains an OPENAI provider (text-embedding-3-small, dimensions 256) gated on a non-blank OPENAI_API_KEY through OllamaPreparationUtils.hasApiKey, with the Ollama case gated on the local daemon only, and the e2e module depends on the new integration. Docs: Java tabs in the OpenAI section of embedding_models.md, FAQ support matrix, YAML alias table. Generated-by: Claude Code 2.1.273 (Claude Fable 5.1) --- .../flink/agents/api/resource/ResourceName.java | 6 + .../org/apache/flink/agents/api/yaml/Aliases.java | 2 + .../apache/flink/agents/api/yaml/AliasesTest.java | 14 + dist/pom.xml | 5 + docs/content/docs/development/embedding_models.md | 99 ++- docs/content/docs/development/yaml.md | 2 +- docs/content/docs/faq/faq.md | 2 +- .../pom.xml | 5 + .../test/EmbeddingIntegrationAgent.java | 13 + .../integration/test/EmbeddingIntegrationTest.java | 86 +- ide-support/pom.xml | 11 + integrations/embedding-models/{ => openai}/pom.xml | 32 +- .../openai/OpenAIEmbeddingModelConnection.java | 556 +++++++++++++ .../openai/OpenAIEmbeddingModelSetup.java | 112 +++ .../openai/OpenAIEmbeddingModelTest.java | 890 +++++++++++++++++++++ integrations/embedding-models/pom.xml | 1 + python/flink_agents/api/resource.py | 4 + python/flink_agents/api/yaml/aliases.py | 2 + python/flink_agents/api/yaml/tests/test_aliases.py | 17 + 19 files changed, 1797 insertions(+), 62 deletions(-) diff --git a/api/src/main/java/org/apache/flink/agents/api/resource/ResourceName.java b/api/src/main/java/org/apache/flink/agents/api/resource/ResourceName.java index 19dc86de..9ccc798f 100644 --- a/api/src/main/java/org/apache/flink/agents/api/resource/ResourceName.java +++ b/api/src/main/java/org/apache/flink/agents/api/resource/ResourceName.java @@ -173,6 +173,12 @@ public final class ResourceName { public static final String BEDROCK_SETUP = "org.apache.flink.agents.integrations.embeddingmodels.bedrock.BedrockEmbeddingModelSetup"; + // OpenAI + public static final String OPENAI_CONNECTION = + "org.apache.flink.agents.integrations.embeddingmodels.openai.OpenAIEmbeddingModelConnection"; + public static final String OPENAI_SETUP = + "org.apache.flink.agents.integrations.embeddingmodels.openai.OpenAIEmbeddingModelSetup"; + // Python Wrapper public static final String PYTHON_WRAPPER_CONNECTION = "org.apache.flink.agents.api.embedding.model.python.PythonEmbeddingModelConnection"; diff --git a/api/src/main/java/org/apache/flink/agents/api/yaml/Aliases.java b/api/src/main/java/org/apache/flink/agents/api/yaml/Aliases.java index ee70e3fe..a82c71e1 100644 --- a/api/src/main/java/org/apache/flink/agents/api/yaml/Aliases.java +++ b/api/src/main/java/org/apache/flink/agents/api/yaml/Aliases.java @@ -126,6 +126,7 @@ public final class Aliases { Map<String, String> embConnJava = new HashMap<>(); embConnJava.put("ollama", ResourceName.EmbeddingModel.OLLAMA_CONNECTION); embConnJava.put("bedrock", ResourceName.EmbeddingModel.BEDROCK_CONNECTION); + embConnJava.put("openai", ResourceName.EmbeddingModel.OPENAI_CONNECTION); Map<String, String> embConnPython = new HashMap<>(); embConnPython.put("ollama", ResourceName.EmbeddingModel.Python.OLLAMA_CONNECTION); embConnPython.put("openai", ResourceName.EmbeddingModel.Python.OPENAI_CONNECTION); @@ -138,6 +139,7 @@ public final class Aliases { Map<String, String> embJava = new HashMap<>(); embJava.put("ollama", ResourceName.EmbeddingModel.OLLAMA_SETUP); embJava.put("bedrock", ResourceName.EmbeddingModel.BEDROCK_SETUP); + embJava.put("openai", ResourceName.EmbeddingModel.OPENAI_SETUP); Map<String, String> embPython = new HashMap<>(); embPython.put("ollama", ResourceName.EmbeddingModel.Python.OLLAMA_SETUP); embPython.put("openai", ResourceName.EmbeddingModel.Python.OPENAI_SETUP); diff --git a/api/src/test/java/org/apache/flink/agents/api/yaml/AliasesTest.java b/api/src/test/java/org/apache/flink/agents/api/yaml/AliasesTest.java index 9d4508c1..152c2e34 100644 --- a/api/src/test/java/org/apache/flink/agents/api/yaml/AliasesTest.java +++ b/api/src/test/java/org/apache/flink/agents/api/yaml/AliasesTest.java @@ -75,6 +75,20 @@ class AliasesTest { assertThat(azureOpenAISetup).isEqualTo(ResourceName.ChatModel.AZURE_OPENAI_SETUP); } + @Test + void clazzAliasCoversEmbeddingModelOpenAIInBothLanguages() { + assertThat( + Aliases.resolveClazz( + "openai", ResourceType.EMBEDDING_MODEL_CONNECTION, Language.JAVA)) + .isEqualTo(ResourceName.EmbeddingModel.OPENAI_CONNECTION); + assertThat(Aliases.resolveClazz("openai", ResourceType.EMBEDDING_MODEL, Language.JAVA)) + .isEqualTo(ResourceName.EmbeddingModel.OPENAI_SETUP); + assertThat( + Aliases.resolveClazz( + "openai", ResourceType.EMBEDDING_MODEL_CONNECTION, Language.PYTHON)) + .isEqualTo(ResourceName.EmbeddingModel.Python.OPENAI_CONNECTION); + } + @Test void clazzAliasCoversVectorStoreJavaAndPython() { String opensearch = diff --git a/dist/pom.xml b/dist/pom.xml index df52c7a3..b59f25b4 100644 --- a/dist/pom.xml +++ b/dist/pom.xml @@ -107,6 +107,11 @@ under the License. <artifactId>flink-agents-integrations-embedding-models-bedrock</artifactId> <version>${project.version}</version> </dependency> + <dependency> + <groupId>org.apache.flink</groupId> + <artifactId>flink-agents-integrations-embedding-models-openai</artifactId> + <version>${project.version}</version> + </dependency> <dependency> <groupId>org.apache.flink</groupId> <artifactId>flink-agents-integrations-vector-stores-elasticsearch</artifactId> diff --git a/docs/content/docs/development/embedding_models.md b/docs/content/docs/development/embedding_models.md index 0966b61d..0de2bb5e 100644 --- a/docs/content/docs/development/embedding_models.md +++ b/docs/content/docs/development/embedding_models.md @@ -419,16 +419,15 @@ Model availability and specifications may change. Always check the official Olla OpenAI provides cloud-based embedding models with state-of-the-art performance. -{{< hint info >}} -OpenAI embedding models are currently supported in the Python API only. To use OpenAI from Java agents, see [Using Cross-Language Providers](#using-cross-language-providers). -{{< /hint >}} - #### Prerequisites 1. Get an API key from [OpenAI Platform](https://platform.openai.com/) #### Usage Example +{{< tabs "OpenAI Embedding Usage Example" >}} + +{{< tab "Python" >}} ```python class MyAgent(Agent): @@ -453,28 +452,106 @@ class MyAgent(Agent): encoding_format="float" ) ``` +{{< /tab >}} + +{{< tab "Java" >}} +```java +public class MyAgent extends Agent { + + @EmbeddingModelConnection + public static ResourceDescriptor openaiConnection() { + return ResourceDescriptor.Builder.newBuilder(ResourceName.EmbeddingModel.OPENAI_CONNECTION) + .addInitialArgument("api_key", System.getenv("OPENAI_API_KEY")) + .addInitialArgument("request_timeout", 30) + .addInitialArgument("max_retries", 3) + .build(); + } + + @EmbeddingModelSetup + public static ResourceDescriptor openaiEmbedding() { + return ResourceDescriptor.Builder.newBuilder(ResourceName.EmbeddingModel.OPENAI_SETUP) + .addInitialArgument("connection", "openaiConnection") + .addInitialArgument("model", "text-embedding-3-small") + .addInitialArgument("dimensions", 512) + .build(); + } + + ... +} +``` +{{< /tab >}} + +{{< /tabs >}} #### OpenAIEmbeddingModelConnection Parameters +{{< tabs "OpenAIEmbeddingModelConnection Parameters" >}} + +{{< tab "Python" >}} + | Parameter | Type | Default | Description | |-----------|------|---------|-------------| -| `api_key` | str | Required | OpenAI API key for authentication | +| `api_key` | str | Required | OpenAI API key for authentication; a blank key is passed through to the SDK for unauthenticated OpenAI-compatible servers (the Java connection rejects a blank key) | | `base_url` | str | `"https://api.openai.com/v1"` | OpenAI API base URL | -| `request_timeout` | float | `30.0` | HTTP request timeout in seconds | +| `request_timeout` | float | `30.0` | HTTP request timeout in seconds; `0` disables the timeout | | `max_retries` | int | `3` | Maximum number of retry attempts | -| `organization` | str | None | Optional organization ID | -| `project` | str | None | Optional project ID | +| `organization` | str | None | Optional organization ID; when unset or blank the Python SDK may read `OPENAI_ORG_ID` from the environment (the Java connection sends no organization) | +| `project` | str | None | Optional project ID; when unset or blank the Python SDK may read `OPENAI_PROJECT_ID` from the environment (the Java connection sends no project) | + +{{< /tab >}} + +{{< tab "Java" >}} + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `api_key` | String | Required | OpenAI API key for authentication | +| `base_url` | String | `"https://api.openai.com/v1"` | OpenAI API base URL | +| `request_timeout` | Number | `30` | HTTP request timeout in seconds; `0` disables the timeout | +| `max_retries` | Number | `3` | Maximum number of retry attempts, a non-negative integer | +| `organization` | String | None | Optional organization ID | +| `project` | String | None | Optional project ID | + +{{< /tab >}} + +{{< /tabs >}} #### OpenAIEmbeddingModelSetup Parameters +{{< tabs "OpenAIEmbeddingModelSetup Parameters" >}} + +{{< tab "Python" >}} + | Parameter | Type | Default | Description | |-----------|------|---------|-------------| | `connection` | str | Required | Reference to connection method name | | `model` | str | Required | OpenAI embedding model name | -| `encoding_format` | str | `"float"` | Return format ("float" or "base64") | +| `encoding_format` | str | `"float"` | Wire format, `"float"` or `"base64"`; base64 responses are decoded to floats | | `dimensions` | int | None | Output dimensions (text-embedding-3 models only) | | `user` | str | None | End-user identifier for monitoring | -| `additional_kwargs` | dict | `{}` | Additional parameters for the OpenAI embeddings API | +| `additional_kwargs` | dict | `{}` | Extra request body properties sent with every embeddings request; keys may not be `model`, `input`, `encoding_format`, `dimensions` or `user`. A per-call `additional_kwargs` replaces this map | + +`additional_kwargs` entries are sent to the API as request body properties; an entry that repeats a typed field fails validation (set the typed argument instead), and `dimensions` must be an integer. + +{{< /tab >}} + +{{< tab "Java" >}} + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `connection` | String | Required | Reference to connection method name | +| `model` | String | Required | OpenAI embedding model name | +| `encoding_format` | String | `"float"` | Wire format, `"float"` or `"base64"`; base64 responses are decoded to floats | +| `dimensions` | Number | None | Output dimensions, a positive integer (text-embedding-3 models only) | +| `user` | String | None | End-user identifier for monitoring | +| `additional_kwargs` | Map | `{}` | Extra request body properties sent with every embeddings request; keys may not be `model`, `input`, `encoding_format`, `dimensions` or `user`. A per-call `additional_kwargs` replaces this map | + + +`additional_kwargs` entries that repeat a typed field fail validation instead of overriding it (set the typed argument instead), and `dimensions` must be a positive integer. +{{< /tab >}} + +{{< /tabs >}} + +A batch of texts is embedded in a single request, so it must stay within OpenAI's per-request limits (2048 inputs at the time of writing); split larger batches before calling. Token usage reported by the API is available through `embedWithUsage` in Java and `embed_with_usage` in Python. #### Available Models @@ -559,7 +636,7 @@ Model availability and specifications may change. Always check the official Dash ## Using Cross-Language Providers -Flink Agents supports cross-language embedding model integration, allowing you to use embedding models implemented in one language (Java or Python) from agents written in the other language. This is particularly useful when an embedding model provider is only available in one language (e.g., OpenAI embedding is currently Python-only). +Flink Agents supports cross-language embedding model integration, allowing you to use embedding models implemented in one language (Java or Python) from agents written in the other language. This is particularly useful when an embedding model provider is only available in one language (e.g., Tongyi embedding is currently Python-only). {{< hint warning >}} **Limitations:** diff --git a/docs/content/docs/development/yaml.md b/docs/content/docs/development/yaml.md index 21893514..c0aa035c 100644 --- a/docs/content/docs/development/yaml.md +++ b/docs/content/docs/development/yaml.md @@ -585,7 +585,7 @@ Embedding-model aliases (apply to both `embedding_model_connections` and `embedd | Alias | `type: python` | `type: java` | | --------- | -------------- | -------------- | | `ollama` | Ollama | Ollama | -| `openai` | OpenAI | — | +| `openai` | OpenAI | OpenAI (Java) | | `tongyi` | Tongyi | — | | `bedrock` | — | Bedrock (Java) | diff --git a/docs/content/docs/faq/faq.md b/docs/content/docs/faq/faq.md index 3f4fe812..b26367de 100644 --- a/docs/content/docs/faq/faq.md +++ b/docs/content/docs/faq/faq.md @@ -113,7 +113,7 @@ Flink Agents provides built-in integrations for many ecosystem providers. Some i |---|---|---| | [Amazon Bedrock]({{< ref "docs/development/embedding_models#amazon-bedrock" >}}) | ❌ | ✅ | | [Ollama]({{< ref "docs/development/embedding_models#ollama" >}}) | ✅ | ✅ | -| [OpenAI]({{< ref "docs/development/embedding_models#openai" >}}) | ✅ | ❌ | +| [OpenAI]({{< ref "docs/development/embedding_models#openai" >}}) | ✅ | ✅ | | [Tongyi (DashScope)]({{< ref "docs/development/embedding_models#tongyi-dashscope" >}}) | ✅ | ❌ | **Vector Stores** diff --git a/e2e-test/flink-agents-end-to-end-tests-integration/pom.xml b/e2e-test/flink-agents-end-to-end-tests-integration/pom.xml index 82d4b941..86370c4e 100644 --- a/e2e-test/flink-agents-end-to-end-tests-integration/pom.xml +++ b/e2e-test/flink-agents-end-to-end-tests-integration/pom.xml @@ -95,6 +95,11 @@ under the License. <artifactId>flink-agents-integrations-embedding-models-ollama</artifactId> <version>${project.version}</version> </dependency> + <dependency> + <groupId>org.apache.flink</groupId> + <artifactId>flink-agents-integrations-embedding-models-openai</artifactId> + <version>${project.version}</version> + </dependency> <dependency> <groupId>org.apache.flink</groupId> <artifactId>flink-agents-integrations-vector-stores-elasticsearch</artifactId> diff --git a/e2e-test/flink-agents-end-to-end-tests-integration/src/test/java/org/apache/flink/agents/integration/test/EmbeddingIntegrationAgent.java b/e2e-test/flink-agents-end-to-end-tests-integration/src/test/java/org/apache/flink/agents/integration/test/EmbeddingIntegrationAgent.java index 2167d133..e9d8fda2 100644 --- a/e2e-test/flink-agents-end-to-end-tests-integration/src/test/java/org/apache/flink/agents/integration/test/EmbeddingIntegrationAgent.java +++ b/e2e-test/flink-agents-end-to-end-tests-integration/src/test/java/org/apache/flink/agents/integration/test/EmbeddingIntegrationAgent.java @@ -48,6 +48,7 @@ import java.util.Map; */ public class EmbeddingIntegrationAgent extends Agent { public static final String OLLAMA_MODEL = "nomic-embed-text"; + public static final String OPENAI_MODEL = "text-embedding-3-small"; private static final ObjectMapper MAPPER = new ObjectMapper(); @EmbeddingModelConnection @@ -59,6 +60,12 @@ public class EmbeddingIntegrationAgent extends Agent { .addInitialArgument("host", "http://localhost:11434") .addInitialArgument("timeout", 60) .build(); + } else if (provider.equals("OPENAI")) { + return ResourceDescriptor.Builder.newBuilder( + ResourceName.EmbeddingModel.OPENAI_CONNECTION) + .addInitialArgument("api_key", System.getenv().get("OPENAI_API_KEY")) + .addInitialArgument("request_timeout", 60) + .build(); } else { throw new RuntimeException(String.format("Unknown model provider %s", provider)); } @@ -72,6 +79,12 @@ public class EmbeddingIntegrationAgent extends Agent { .addInitialArgument("connection", "embeddingConnection") .addInitialArgument("model", OLLAMA_MODEL) .build(); + } else if (provider.equals("OPENAI")) { + return ResourceDescriptor.Builder.newBuilder(ResourceName.EmbeddingModel.OPENAI_SETUP) + .addInitialArgument("connection", "embeddingConnection") + .addInitialArgument("model", OPENAI_MODEL) + .addInitialArgument("dimensions", 256) + .build(); } else { throw new RuntimeException(String.format("Unknown model provider %s", provider)); } diff --git a/e2e-test/flink-agents-end-to-end-tests-integration/src/test/java/org/apache/flink/agents/integration/test/EmbeddingIntegrationTest.java b/e2e-test/flink-agents-end-to-end-tests-integration/src/test/java/org/apache/flink/agents/integration/test/EmbeddingIntegrationTest.java index baaf2f68..a0d3ad7a 100644 --- a/e2e-test/flink-agents-end-to-end-tests-integration/src/test/java/org/apache/flink/agents/integration/test/EmbeddingIntegrationTest.java +++ b/e2e-test/flink-agents-end-to-end-tests-integration/src/test/java/org/apache/flink/agents/integration/test/EmbeddingIntegrationTest.java @@ -32,13 +32,13 @@ import java.io.IOException; import java.util.Map; import static org.apache.flink.agents.integration.test.EmbeddingIntegrationAgent.OLLAMA_MODEL; +import static org.apache.flink.agents.integration.test.OllamaPreparationUtils.hasApiKey; import static org.apache.flink.agents.integration.test.OllamaPreparationUtils.pullModel; /** * Example application that applies {@link EmbeddingIntegrationAgent} to a DataStream of prompts. */ public class EmbeddingIntegrationTest { - private static final String API_KEY = "_API_KEY"; private static final String OLLAMA = "OLLAMA"; private final boolean ollamaReady; @@ -48,52 +48,56 @@ public class EmbeddingIntegrationTest { } @ParameterizedTest() - @ValueSource(strings = {"OLLAMA"}) + @ValueSource(strings = {"OLLAMA", "OPENAI"}) public void testEmbeddingIntegration(String provider) throws Exception { Assumptions.assumeTrue( - (OLLAMA.equals(provider) && ollamaReady) - || System.getenv().get(provider + API_KEY) != null, + OLLAMA.equals(provider) ? ollamaReady : hasApiKey(provider), String.format( "Server or authentication information is not provided for %s", provider)); System.setProperty("MODEL_PROVIDER", provider); - - // Create the execution environment - StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment(); - env.setParallelism(1); - - // Use prompts that exercise embedding generation and similarity checks - DataStream<String> inputStream = - env.fromData( - "Generate embedding for: 'Machine learning'", - "Generate embedding for: 'Deep learning techniques'", - "Find texts similar to: 'neural networks'", - "Produce embedding and return top-3 similar items for: 'natural language processing'", - "Generate embedding for: 'hello world'", - "Compare similarity between 'cat' and 'dog'", - "Create embedding for: 'space exploration'", - "Find nearest neighbors for: 'artificial intelligence'", - "Generate embedding for: 'data science'", - "Random embedding test"); - - // Create agents execution environment - AgentsExecutionEnvironment agentsEnv = - AgentsExecutionEnvironment.getExecutionEnvironment(env); - - // Apply agent to the DataStream and use the prompt itself as the key - DataStream<Object> outputStream = - agentsEnv - .fromDataStream(inputStream, (KeySelector<String, String>) value -> value) - .apply(new EmbeddingIntegrationAgent()) - .toDataStream(); - - // Collect the results - CloseableIterator<Object> results = outputStream.collectAsync(); - - // Execute the pipeline - agentsEnv.execute(); - - checkResult(results); + try { + + // Create the execution environment + StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment(); + env.setParallelism(1); + + // Use prompts that exercise embedding generation and similarity checks + DataStream<String> inputStream = + env.fromData( + "Generate embedding for: 'Machine learning'", + "Generate embedding for: 'Deep learning techniques'", + "Find texts similar to: 'neural networks'", + "Produce embedding and return top-3 similar items for: 'natural language processing'", + "Generate embedding for: 'hello world'", + "Compare similarity between 'cat' and 'dog'", + "Create embedding for: 'space exploration'", + "Find nearest neighbors for: 'artificial intelligence'", + "Generate embedding for: 'data science'", + "Random embedding test"); + + // Create agents execution environment + AgentsExecutionEnvironment agentsEnv = + AgentsExecutionEnvironment.getExecutionEnvironment(env); + + // Apply agent to the DataStream and use the prompt itself as the key + DataStream<Object> outputStream = + agentsEnv + .fromDataStream( + inputStream, (KeySelector<String, String>) value -> value) + .apply(new EmbeddingIntegrationAgent()) + .toDataStream(); + + // Collect the results + CloseableIterator<Object> results = outputStream.collectAsync(); + + // Execute the pipeline + agentsEnv.execute(); + + checkResult(results); + } finally { + System.clearProperty("MODEL_PROVIDER"); + } } @SuppressWarnings("unchecked") diff --git a/ide-support/pom.xml b/ide-support/pom.xml index 5ce239a9..d7b07853 100644 --- a/ide-support/pom.xml +++ b/ide-support/pom.xml @@ -90,6 +90,17 @@ under the License. </exclusion> </exclusions> </dependency> + <dependency> + <groupId>org.apache.flink</groupId> + <artifactId>flink-agents-integrations-embedding-models-openai</artifactId> + <version>${project.version}</version> + <exclusions> + <exclusion> + <groupId>org.apache.flink</groupId> + <artifactId>flink-agents-api</artifactId> + </exclusion> + </exclusions> + </dependency> <dependency> <groupId>org.apache.flink</groupId> <artifactId>flink-agents-integrations-vector-stores-elasticsearch</artifactId> diff --git a/integrations/embedding-models/pom.xml b/integrations/embedding-models/openai/pom.xml similarity index 55% copy from integrations/embedding-models/pom.xml copy to integrations/embedding-models/openai/pom.xml index 2a585d17..f905294d 100644 --- a/integrations/embedding-models/pom.xml +++ b/integrations/embedding-models/openai/pom.xml @@ -22,17 +22,33 @@ under the License. <parent> <groupId>org.apache.flink</groupId> - <artifactId>flink-agents-integrations</artifactId> + <artifactId>flink-agents-integrations-embedding-models</artifactId> <version>0.4-SNAPSHOT</version> + <relativePath>../pom.xml</relativePath> </parent> - <artifactId>flink-agents-integrations-embedding-models</artifactId> - <name>Flink Agents : Integrations: Embedding Models</name> - <packaging>pom</packaging> + <artifactId>flink-agents-integrations-embedding-models-openai</artifactId> + <name>Flink Agents : Integrations: Embedding Models: OpenAI</name> + <packaging>jar</packaging> - <modules> - <module>bedrock</module> - <module>ollama</module> - </modules> + <dependencies> + <dependency> + <groupId>org.apache.flink</groupId> + <artifactId>flink-agents-api</artifactId> + <version>${project.version}</version> + </dependency> + + <dependency> + <groupId>com.openai</groupId> + <artifactId>openai-java</artifactId> + <version>${openai.version}</version> + </dependency> + <dependency> + <groupId>org.apache.flink</groupId> + <artifactId>flink-core</artifactId> + <version>${flink.version}</version> + <scope>test</scope> + </dependency> + </dependencies> </project> diff --git a/integrations/embedding-models/openai/src/main/java/org/apache/flink/agents/integrations/embeddingmodels/openai/OpenAIEmbeddingModelConnection.java b/integrations/embedding-models/openai/src/main/java/org/apache/flink/agents/integrations/embeddingmodels/openai/OpenAIEmbeddingModelConnection.java new file mode 100644 index 00000000..c58aa952 --- /dev/null +++ b/integrations/embedding-models/openai/src/main/java/org/apache/flink/agents/integrations/embeddingmodels/openai/OpenAIEmbeddingModelConnection.java @@ -0,0 +1,556 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.flink.agents.integrations.embeddingmodels.openai; + +import com.openai.client.OpenAIClient; +import com.openai.client.okhttp.OpenAIOkHttpClient; +import com.openai.core.JsonField; +import com.openai.core.JsonString; +import com.openai.core.JsonValue; +import com.openai.core.Timeout; +import com.openai.models.embeddings.CreateEmbeddingResponse; +import com.openai.models.embeddings.Embedding; +import com.openai.models.embeddings.EmbeddingCreateParams; +import com.openai.models.embeddings.EmbeddingValue; +import org.apache.flink.agents.api.embedding.model.BaseEmbeddingModelConnection; +import org.apache.flink.agents.api.embedding.model.EmbeddingModelUtils; +import org.apache.flink.agents.api.embedding.model.EmbeddingResult; +import org.apache.flink.agents.api.embedding.model.EmbeddingTokenUsage; +import org.apache.flink.agents.api.resource.ResourceContext; +import org.apache.flink.agents.api.resource.ResourceDescriptor; + +import java.math.BigDecimal; +import java.math.BigInteger; +import java.math.RoundingMode; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.time.Duration; +import java.util.ArrayList; +import java.util.Base64; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.TreeSet; + +/** + * Embedding model connection backed by the OpenAI Embeddings API. + * + * <p>Argument names mirror the Python {@code OpenAIEmbeddingModelConnection}: + * + * <ul> + * <li>{@code api_key} (required): OpenAI API key. + * <li>{@code base_url}: API base URL, default {@code https://api.openai.com/v1}. + * <li>{@code request_timeout}: per-attempt request timeout in seconds, default {@code 30}; {@code + * 0} disables the timeout, as in the other OpenAI connections. + * <li>{@code max_retries}: SDK retry count for failed requests, default {@code 3}. + * <li>{@code organization}, {@code project}: optional OpenAI organization and project IDs. + * </ul> + * + * <p>Per-request parameters come from {@link OpenAIEmbeddingModelSetup#getParameters()} merged with + * any per-call parameters: {@code model} (required), {@code encoding_format} ({@code "float"} or + * {@code "base64"}), {@code dimensions}, {@code user}, and {@code additional_kwargs}, a map whose + * entries are sent as extra request body properties. Its keys must not collide with the typed + * request fields ({@code model}, {@code input}, {@code encoding_format}, {@code dimensions}, {@code + * user}); entries with a {@code null} value are omitted. Blank strings are treated as absent, and + * other per-call keys are ignored. A batch of texts is sent as one request and the embeddings are + * returned in input order. Token usage reported by the API is surfaced through {@link + * #embedWithUsage}; servers that omit it yield a {@code null} usage. + */ +public class OpenAIEmbeddingModelConnection extends BaseEmbeddingModelConnection { + + static final String DEFAULT_BASE_URL = "https://api.openai.com/v1"; + static final int DEFAULT_REQUEST_TIMEOUT_SECONDS = 30; + static final int DEFAULT_MAX_RETRIES = 3; + /** The Java SDK stores timeouts as int milliseconds. */ + static final BigDecimal MAX_REQUEST_TIMEOUT_SECONDS = new BigDecimal("2147483.647"); + + static final String MODEL = "model"; + static final String ENCODING_FORMAT = "encoding_format"; + static final String DIMENSIONS = "dimensions"; + static final String USER = "user"; + /** Setup parameter carrying extra request body properties. */ + static final String ADDITIONAL_KWARGS = "additional_kwargs"; + + static final Set<String> ENCODING_FORMATS = Set.of("float", "base64"); + /** + * Request fields owned by the typed builder calls; {@code additional_kwargs} may not set them. + */ + static final Set<String> RESERVED_ADDITIONAL_KWARGS = + Set.of(MODEL, "input", ENCODING_FORMAT, DIMENSIONS, USER); + + private final OpenAIClient client; + + public OpenAIEmbeddingModelConnection( + ResourceDescriptor descriptor, ResourceContext resourceContext) { + super(descriptor, resourceContext); + this.client = ClientConfig.parse(descriptor).buildClient(); + } + + // visible for testing + OpenAIEmbeddingModelConnection( + ResourceDescriptor descriptor, ResourceContext resourceContext, OpenAIClient client) { + super(descriptor, resourceContext); + this.client = client; + } + + /** The parsed connection arguments; tests assert on them through {@link #parse}. */ + static final class ClientConfig { + final String apiKey; + final String baseUrl; + final Duration requestTimeout; + final int maxRetries; + final String organization; + final String project; + + private ClientConfig( + String apiKey, + String baseUrl, + Duration requestTimeout, + int maxRetries, + String organization, + String project) { + this.apiKey = apiKey; + this.baseUrl = baseUrl; + this.requestTimeout = requestTimeout; + this.maxRetries = maxRetries; + this.organization = organization; + this.project = project; + } + + /** Blank string arguments are treated as absent. */ + static ClientConfig parse(ResourceDescriptor descriptor) { + String apiKey = requireString(descriptor.getArgument("api_key"), "api_key"); + if (apiKey == null || apiKey.isBlank()) { + throw new IllegalArgumentException("api_key should not be null or empty."); + } + String baseUrl = requireString(descriptor.getArgument("base_url"), "base_url"); + return new ClientConfig( + apiKey, + baseUrl == null || baseUrl.isBlank() ? DEFAULT_BASE_URL : baseUrl, + parseRequestTimeout(descriptor.getArgument("request_timeout")), + parseMaxRetries(descriptor.getArgument("max_retries")), + blankToNull( + requireString(descriptor.getArgument("organization"), "organization")), + blankToNull(requireString(descriptor.getArgument("project"), "project"))); + } + + OpenAIClient buildClient() { + OpenAIOkHttpClient.Builder builder = + new OpenAIOkHttpClient.Builder() + .apiKey(apiKey) + .baseUrl(baseUrl) + .timeout( + Timeout.builder() + .connect(requestTimeout) + .read(requestTimeout) + .write(requestTimeout) + .request(requestTimeout) + .build()) + .maxRetries(maxRetries); + if (organization != null) { + builder.organization(organization); + } + if (project != null) { + builder.project(project); + } + return builder.build(); + } + } + + /** Blank string arguments are treated as absent. */ + static String blankToNull(String value) { + return value == null || value.isBlank() ? null : value; + } + + /** Parses {@code request_timeout} seconds with the same rules as the OpenAI chat connection. */ + static Duration parseRequestTimeout(Object raw) { + if (raw == null) { + return Duration.ofSeconds(DEFAULT_REQUEST_TIMEOUT_SECONDS); + } + BigDecimal seconds = toBigDecimal(raw, "request_timeout"); + if (seconds.signum() < 0) { + throw new IllegalArgumentException("request_timeout must be >= 0, got: " + raw); + } + if (seconds.compareTo(MAX_REQUEST_TIMEOUT_SECONDS) > 0) { + throw new IllegalArgumentException( + "request_timeout exceeds the SDK maximum of " + + MAX_REQUEST_TIMEOUT_SECONDS.toPlainString() + + " seconds, got: " + + raw); + } + // Millisecond precision; positive values round up so a valid timeout never becomes zero, + // which would disable it. Exactly 0 disables the timeout, like the chat connection. + BigInteger millis = + seconds.multiply(BigDecimal.valueOf(1_000L)) + .setScale(0, RoundingMode.CEILING) + .toBigIntegerExact(); + return Duration.ofMillis(millis.longValueExact()); + } + + static int parseMaxRetries(Object raw) { + if (raw == null) { + return DEFAULT_MAX_RETRIES; + } + BigDecimal value = toBigDecimal(raw, "max_retries"); + try { + BigInteger retries = value.toBigIntegerExact(); + if (retries.signum() < 0 + || retries.compareTo(BigInteger.valueOf(Integer.MAX_VALUE)) > 0) { + throw new IllegalArgumentException( + "max_retries must be a non-negative integer, got: " + raw); + } + return retries.intValueExact(); + } catch (ArithmeticException e) { + throw new IllegalArgumentException( + "max_retries must be a non-negative integer, got: " + raw, e); + } + } + + private static BigDecimal toBigDecimal(Object raw, String argumentName) { + if (!(raw instanceof Number)) { + throw new IllegalArgumentException(argumentName + " must be a number, got: " + raw); + } + Number number = (Number) raw; + if ((number instanceof Double || number instanceof Float) + && !Double.isFinite(number.doubleValue())) { + throw new IllegalArgumentException(argumentName + " must be finite, got: " + raw); + } + try { + return new BigDecimal(number.toString()); + } catch (NumberFormatException e) { + throw new IllegalArgumentException( + argumentName + " must be a finite number, got: " + raw, e); + } + } + + // ---- request parameter parsing, shared by the setup (fail fast) and buildParams (per call) + + static String requireString(Object value, String argumentName) { + if (value != null && !(value instanceof String)) { + throw new IllegalArgumentException( + argumentName + " must be a string, got: " + value.getClass().getSimpleName()); + } + return (String) value; + } + + static String parseModel(Object value) { + String model = requireString(value, MODEL); + if (model == null || model.isBlank()) { + throw new IllegalArgumentException( + "OpenAI embedding requires a non-empty 'model' (setup or per-call parameter)."); + } + return model; + } + + static String parseEncodingFormat(Object value) { + String format = requireString(value, ENCODING_FORMAT); + if (format == null || format.isBlank()) { + return OpenAIEmbeddingModelSetup.DEFAULT_ENCODING_FORMAT; + } + if (!ENCODING_FORMATS.contains(format)) { + throw new IllegalArgumentException( + ENCODING_FORMAT + " must be one of " + ENCODING_FORMATS + ", got: " + format); + } + return format; + } + + static Integer parseDimensions(Object value) { + if (value == null) { + return null; + } + BigDecimal number = toBigDecimal(value, DIMENSIONS); + try { + BigInteger dims = number.toBigIntegerExact(); + if (dims.signum() <= 0 || dims.compareTo(BigInteger.valueOf(Integer.MAX_VALUE)) > 0) { + throw new IllegalArgumentException( + DIMENSIONS + " must be a positive integer, got: " + value); + } + return dims.intValueExact(); + } catch (ArithmeticException e) { + throw new IllegalArgumentException( + DIMENSIONS + " must be a positive integer, got: " + value, e); + } + } + + static Map<String, Object> parseAdditionalKwargs(Object value) { + Map<String, Object> result = new LinkedHashMap<>(); + if (value == null) { + return result; + } + if (!(value instanceof Map)) { + throw new IllegalArgumentException( + ADDITIONAL_KWARGS + " must be a map, got: " + value.getClass().getSimpleName()); + } + Set<String> collisions = null; + for (Map.Entry<?, ?> entry : ((Map<?, ?>) value).entrySet()) { + if (entry.getKey() == null || entry.getKey().toString().isBlank()) { + throw new IllegalArgumentException(ADDITIONAL_KWARGS + " contains an empty key."); + } + String key = entry.getKey().toString(); + if (RESERVED_ADDITIONAL_KWARGS.contains(key)) { + if (collisions == null) { + collisions = new TreeSet<>(); + } + collisions.add(key); + } + result.put(key, entry.getValue()); + } + if (collisions != null) { + throw new IllegalArgumentException( + ADDITIONAL_KWARGS + + " must not contain the typed request fields " + + collisions + + "; set them through the corresponding setup argument instead."); + } + return result; + } + + @Override + public float[] embed(String text, Map<String, Object> parameters) { + return embedWithUsage(text, parameters).getEmbeddings(); + } + + @Override + public List<float[]> embed(List<String> texts, Map<String, Object> parameters) { + return embedWithUsage(texts, parameters).getEmbeddings(); + } + + @Override + public EmbeddingResult<float[]> embedWithUsage(String text, Map<String, Object> parameters) { + EmbeddingResult<List<float[]>> result = + embedWithUsage(Collections.singletonList(text), parameters); + return new EmbeddingResult<>(result.getEmbeddings().get(0), result.getTokenUsage()); + } + + @Override + public EmbeddingResult<List<float[]>> embedWithUsage( + List<String> texts, Map<String, Object> parameters) { + if (texts == null) { + throw new IllegalArgumentException("texts must not be null."); + } + List<String> inputs = texts; + for (int i = 0; i < inputs.size(); i++) { + if (inputs.get(i) == null) { + throw new IllegalArgumentException( + "Text at index " + i + " is null; every input must be a string."); + } + } + // Validate the parameters even for an empty batch so a bad per-call override is not + // deferred to the first non-empty batch (the Python connection validates first too). + EmbeddingCreateParams params = + buildParams(inputs, parameters == null ? Collections.emptyMap() : parameters); + if (inputs.isEmpty()) { + return new EmbeddingResult<>(new ArrayList<>(), null); + } + CreateEmbeddingResponse response = client.embeddings().create(params); + + // A compatible server may answer 200 without `data`; the count check below reports it + // instead of the SDK's required-field exception. + JsonField<List<Embedding>> dataField = response._data(); + List<Embedding> data; + if (dataField.isMissing() || dataField.isNull()) { + data = Collections.emptyList(); + } else { + // Present but not a list of embeddings: report the payload, not a count of zero. + data = + dataField + .asKnown() + .orElseThrow( + () -> + new RuntimeException( + "OpenAI returned a malformed data field: " + + dataField.asUnknown().orElse(null))); + } + if (data.size() != texts.size()) { + throw new RuntimeException( + String.format( + "OpenAI returned %d embeddings for %d input texts.", + data.size(), texts.size())); + } + boolean base64 = + params.encodingFormat() + .map(EmbeddingCreateParams.EncodingFormat.BASE64::equals) + .orElse(false); + return new EmbeddingResult<>(orderEmbeddings(data, base64), extractTokenUsage(response)); + } + + /** + * Places each vector at its response {@code index} so input order is preserved even if the API + * reorders; an item without an index (some OpenAI-compatible servers omit it) keeps its + * response position. Every slot must be filled exactly once, so a response whose indices are + * out of range, duplicated, or contradict the positions of index-less items is rejected rather + * than silently mapped. + */ + private static List<float[]> orderEmbeddings(List<Embedding> data, boolean base64) { + float[][] ordered = new float[data.size()][]; + for (int position = 0; position < data.size(); position++) { + Embedding embedding = data.get(position); + if (embedding == null) { + throw new RuntimeException( + "OpenAI returned a malformed embedding at position " + position + "."); + } + JsonField<Long> index = embedding._index(); + long target; + if (index.isMissing() || index.isNull()) { + target = position; + } else { + // A present but non-integer index is a malformed response, not an absent one. + Optional<Long> known = index.asKnown(); + if (known.isEmpty()) { + throw new RuntimeException( + String.format( + "OpenAI returned a non-integer embedding index %s at position %d.", + index.asUnknown().orElse(null), position)); + } + target = known.get(); + } + if (target < 0 || target >= data.size() || ordered[(int) target] != null) { + throw new RuntimeException( + String.format( + "OpenAI returned an unexpected embedding index %d at position %d.", + target, position)); + } + try { + ordered[(int) target] = toVector(embedding, base64); + } catch (RuntimeException e) { + // A null, non-numeric or undecodable vector; name the position as Python does. + throw new RuntimeException( + "OpenAI returned a malformed embedding at position " + position + ".", e); + } + } + return new ArrayList<>(List.of(ordered)); + } + + /** + * Decodes one response vector. Base64 payloads are decoded here rather than through the SDK, + * which silently drops trailing bytes when the length is not a multiple of four; the wire + * format is little-endian float32. + */ + private static float[] toVector(Embedding embedding, boolean base64Requested) { + EmbeddingValue value = embedding.embeddingValue(); + if (value.isBase64()) { + if (!base64Requested) { + // A string vector is only meaningful when base64 was asked for. + throw new IllegalArgumentException( + "a string vector was returned although encoding_format was float."); + } + byte[] bytes = Base64.getDecoder().decode(value.asBase64()); + if (bytes.length % Float.BYTES != 0) { + throw new IllegalArgumentException( + "base64 embedding has " + bytes.length + " bytes, not a multiple of 4."); + } + float[] vector = new float[bytes.length / Float.BYTES]; + ByteBuffer.wrap(bytes).order(ByteOrder.LITTLE_ENDIAN).asFloatBuffer().get(vector); + return vector; + } + return EmbeddingModelUtils.toFloatArray(value.asFloats()); + } + + /** + * Converts an {@code additional_kwargs} value, naming the key if the SDK cannot serialize it. + */ + static JsonValue toJsonValue(String key, Object value) { + try { + return JsonValue.from(value); + } catch (RuntimeException e) { + throw new IllegalArgumentException( + ADDITIONAL_KWARGS + + " value for key '" + + key + + "' cannot be sent as JSON: " + + e.getMessage(), + e); + } + } + + static EmbeddingCreateParams buildParams(List<String> texts, Map<String, Object> parameters) { + EmbeddingCreateParams.Builder builder = + EmbeddingCreateParams.builder() + .model(parseModel(parameters.get(MODEL))) + .inputOfArrayOfStrings(texts) + .encodingFormat( + EmbeddingCreateParams.EncodingFormat.of( + parseEncodingFormat(parameters.get(ENCODING_FORMAT)))); + Integer dimensions = parseDimensions(parameters.get(DIMENSIONS)); + if (dimensions != null) { + builder.dimensions(dimensions); + } + String user = requireString(parameters.get(USER), USER); + if (user != null && !user.isBlank()) { + builder.user(user); + } + for (Map.Entry<String, Object> entry : + parseAdditionalKwargs(parameters.get(ADDITIONAL_KWARGS)).entrySet()) { + if (entry.getValue() != null) { + builder.putAdditionalBodyProperty( + entry.getKey(), toJsonValue(entry.getKey(), entry.getValue())); + } + } + return builder.build(); + } + + /** + * Tolerates servers that omit {@code usage} or some of its fields. Embedding requests have no + * completion tokens, so a missing side of the usage equals the other side (as the Tongyi + * connection does) rather than being reported as zero. + */ + private static EmbeddingTokenUsage extractTokenUsage(CreateEmbeddingResponse response) { + Optional<CreateEmbeddingResponse.Usage> usage = response._usage().asKnown(); + if (usage.isEmpty()) { + return null; + } + Optional<Long> prompt = usageCount(usage.get()._promptTokens()); + Optional<Long> total = usageCount(usage.get()._totalTokens()); + if (prompt.isEmpty() && total.isEmpty()) { + return null; + } + long promptTokens = prompt.orElseGet(total::get); + return new EmbeddingTokenUsage(promptTokens, total.orElse(promptTokens)); + } + + /** + * A token count, also accepting the decimal-string form some compatible servers send (as the + * Python connection does); anything else is treated as absent. + */ + private static Optional<Long> usageCount(JsonField<Long> field) { + Optional<Long> known = field.asKnown(); + if (known.isPresent()) { + return known; + } + return field.asUnknown() + .filter(value -> value instanceof JsonString) + .map(value -> ((JsonString) value).value().trim()) + .filter(text -> !text.isEmpty() && text.chars().allMatch(Character::isDigit)) + .flatMap( + text -> { + try { + return Optional.of(Long.parseLong(text)); + } catch (NumberFormatException e) { + return Optional.empty(); + } + }); + } + + @Override + public void close() { + client.close(); + } +} diff --git a/integrations/embedding-models/openai/src/main/java/org/apache/flink/agents/integrations/embeddingmodels/openai/OpenAIEmbeddingModelSetup.java b/integrations/embedding-models/openai/src/main/java/org/apache/flink/agents/integrations/embeddingmodels/openai/OpenAIEmbeddingModelSetup.java new file mode 100644 index 00000000..842b20e1 --- /dev/null +++ b/integrations/embedding-models/openai/src/main/java/org/apache/flink/agents/integrations/embeddingmodels/openai/OpenAIEmbeddingModelSetup.java @@ -0,0 +1,112 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.flink.agents.integrations.embeddingmodels.openai; + +import org.apache.flink.agents.api.embedding.model.BaseEmbeddingModelSetup; +import org.apache.flink.agents.api.resource.ResourceContext; +import org.apache.flink.agents.api.resource.ResourceDescriptor; + +import java.util.HashMap; +import java.util.Map; + +/** + * Embedding model setup for the OpenAI Embeddings API. + * + * <p>Arguments mirror the Python {@code OpenAIEmbeddingModelSetup}: {@code connection} and {@code + * model} (required), {@code encoding_format} (default {@code "float"}, or {@code "base64"}), {@code + * dimensions} (text-embedding-3 models only), {@code user}, and {@code additional_kwargs}, a map of + * extra request body properties sent with every request whose keys may not repeat the typed request + * fields. Blank string arguments are treated as absent. Per-call parameters override these entry by + * entry, so a per-call {@code additional_kwargs} map replaces the setup's map rather than merging + * with it. + */ +public class OpenAIEmbeddingModelSetup extends BaseEmbeddingModelSetup { + + static final String DEFAULT_ENCODING_FORMAT = "float"; + static final String CONNECTION = "connection"; + + private final String encodingFormat; + private final Integer dimensions; + private final String user; + private final Map<String, Object> additionalKwargs; + + public OpenAIEmbeddingModelSetup( + ResourceDescriptor descriptor, ResourceContext resourceContext) { + // Fail fast at construction; the connection re-applies the same rules per call so that + // per-call overrides are validated identically. + super(validate(descriptor), resourceContext); + this.encodingFormat = + OpenAIEmbeddingModelConnection.parseEncodingFormat( + descriptor.getArgument(OpenAIEmbeddingModelConnection.ENCODING_FORMAT)); + this.dimensions = + OpenAIEmbeddingModelConnection.parseDimensions( + descriptor.getArgument(OpenAIEmbeddingModelConnection.DIMENSIONS)); + this.user = + OpenAIEmbeddingModelConnection.blankToNull( + OpenAIEmbeddingModelConnection.requireString( + descriptor.getArgument(OpenAIEmbeddingModelConnection.USER), + OpenAIEmbeddingModelConnection.USER)); + this.additionalKwargs = + OpenAIEmbeddingModelConnection.parseAdditionalKwargs( + descriptor.getArgument(OpenAIEmbeddingModelConnection.ADDITIONAL_KWARGS)); + // Values are sent as JSON on every request; probe the conversion here so a value the SDK + // cannot + // serialize fails at construction rather than on the first embed call. + for (Map.Entry<String, Object> entry : additionalKwargs.entrySet()) { + if (entry.getValue() != null) { + OpenAIEmbeddingModelConnection.toJsonValue(entry.getKey(), entry.getValue()); + } + } + } + + /** + * Validates {@code connection} and {@code model} before {@link BaseEmbeddingModelSetup} casts + * them to Strings, so a missing or non-string value fails here with a named error instead of a + * {@link ClassCastException} or a lookup failure at {@code open()}. + */ + private static ResourceDescriptor validate(ResourceDescriptor descriptor) { + Object connection = descriptor.getArgument(CONNECTION); + String name = OpenAIEmbeddingModelConnection.requireString(connection, CONNECTION); + if (name == null || name.isBlank()) { + throw new IllegalArgumentException( + "OpenAI embedding requires a non-empty 'connection' on the embedding model setup."); + } + Object model = descriptor.getArgument(OpenAIEmbeddingModelConnection.MODEL); + OpenAIEmbeddingModelConnection.parseModel(model); + return descriptor; + } + + @Override + public Map<String, Object> getParameters() { + Map<String, Object> parameters = new HashMap<>(); + parameters.put(OpenAIEmbeddingModelConnection.MODEL, model); + parameters.put(OpenAIEmbeddingModelConnection.ENCODING_FORMAT, encodingFormat); + if (dimensions != null) { + parameters.put(OpenAIEmbeddingModelConnection.DIMENSIONS, dimensions); + } + if (user != null) { + parameters.put(OpenAIEmbeddingModelConnection.USER, user); + } + if (!additionalKwargs.isEmpty()) { + parameters.put( + OpenAIEmbeddingModelConnection.ADDITIONAL_KWARGS, + new HashMap<>(additionalKwargs)); + } + return parameters; + } +} diff --git a/integrations/embedding-models/openai/src/test/java/org/apache/flink/agents/integrations/embeddingmodels/openai/OpenAIEmbeddingModelTest.java b/integrations/embedding-models/openai/src/test/java/org/apache/flink/agents/integrations/embeddingmodels/openai/OpenAIEmbeddingModelTest.java new file mode 100644 index 00000000..892e721f --- /dev/null +++ b/integrations/embedding-models/openai/src/test/java/org/apache/flink/agents/integrations/embeddingmodels/openai/OpenAIEmbeddingModelTest.java @@ -0,0 +1,890 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.flink.agents.integrations.embeddingmodels.openai; + +import com.openai.client.OpenAIClient; +import com.openai.core.JsonField; +import com.openai.core.JsonMissing; +import com.openai.core.JsonValue; +import com.openai.models.embeddings.CreateEmbeddingResponse; +import com.openai.models.embeddings.Embedding; +import com.openai.models.embeddings.EmbeddingCreateParams; +import com.openai.models.embeddings.EmbeddingValue; +import com.openai.services.blocking.EmbeddingService; +import org.apache.flink.agents.api.embedding.model.BaseEmbeddingModelConnection; +import org.apache.flink.agents.api.embedding.model.BaseEmbeddingModelSetup; +import org.apache.flink.agents.api.embedding.model.EmbeddingResult; +import org.apache.flink.agents.api.resource.ResourceContext; +import org.apache.flink.agents.api.resource.ResourceDescriptor; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.time.Duration; +import java.util.Base64; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class OpenAIEmbeddingModelTest { + + private static final ResourceContext NOOP = ResourceContext.fromGetResource((a, b) -> null); + + private static ResourceDescriptor connDescriptor() { + return ResourceDescriptor.Builder.newBuilder(OpenAIEmbeddingModelConnection.class.getName()) + .addInitialArgument("api_key", "test-key") + .build(); + } + + private static Embedding embedding(long index, float... values) { + Embedding.Builder builder = + Embedding.builder().index(index).object_(JsonValue.from("embedding")); + List<Float> boxed = new java.util.ArrayList<>(); + for (float v : values) { + boxed.add(v); + } + return builder.embedding(boxed).build(); + } + + private static CreateEmbeddingResponse response(long promptTokens, Embedding... data) { + CreateEmbeddingResponse.Builder builder = + CreateEmbeddingResponse.builder() + .model("text-embedding-3-small") + .object_(JsonValue.from("list")) + .usage( + CreateEmbeddingResponse.Usage.builder() + .promptTokens(promptTokens) + .totalTokens(promptTokens) + .build()); + for (Embedding e : data) { + builder.addData(e); + } + return builder.build(); + } + + private static OpenAIEmbeddingModelConnection connectionWith( + OpenAIClient client, EmbeddingService service) { + when(client.embeddings()).thenReturn(service); + return new OpenAIEmbeddingModelConnection(connDescriptor(), NOOP, client); + } + + @Test + @DisplayName("Connection constructor builds a client with defaults") + void testConnectionDefaults() { + OpenAIEmbeddingModelConnection conn = + new OpenAIEmbeddingModelConnection(connDescriptor(), NOOP); + assertThat(conn).isInstanceOf(BaseEmbeddingModelConnection.class); + OpenAIEmbeddingModelConnection.ClientConfig config = + OpenAIEmbeddingModelConnection.ClientConfig.parse(connDescriptor()); + assertThat(config.apiKey).isEqualTo("test-key"); + assertThat(config.baseUrl).isEqualTo(OpenAIEmbeddingModelConnection.DEFAULT_BASE_URL); + assertThat(config.requestTimeout).isEqualTo(Duration.ofSeconds(30)); + assertThat(config.maxRetries).isEqualTo(3); + assertThat(config.organization).isNull(); + assertThat(config.project).isNull(); + conn.close(); + } + + @Test + @DisplayName("Connection accepts organization, project, base_url and numeric options") + void testConnectionExplicitParams() { + ResourceDescriptor desc = + ResourceDescriptor.Builder.newBuilder( + OpenAIEmbeddingModelConnection.class.getName()) + .addInitialArgument("api_key", "test-key") + .addInitialArgument("base_url", "https://example.invalid/v1") + .addInitialArgument("request_timeout", 12.5) + .addInitialArgument("max_retries", 0) + .addInitialArgument("organization", "org-1") + .addInitialArgument("project", "proj-1") + .build(); + OpenAIEmbeddingModelConnection conn = new OpenAIEmbeddingModelConnection(desc, NOOP); + OpenAIEmbeddingModelConnection.ClientConfig config = + OpenAIEmbeddingModelConnection.ClientConfig.parse(desc); + assertThat(config.baseUrl).isEqualTo("https://example.invalid/v1"); + assertThat(config.requestTimeout).isEqualTo(Duration.ofMillis(12_500)); + assertThat(config.maxRetries).isZero(); + assertThat(config.organization).isEqualTo("org-1"); + assertThat(config.project).isEqualTo("proj-1"); + conn.close(); + + ResourceDescriptor blanks = + ResourceDescriptor.Builder.newBuilder( + OpenAIEmbeddingModelConnection.class.getName()) + .addInitialArgument("api_key", "test-key") + .addInitialArgument("base_url", " ") + .addInitialArgument("organization", "") + .build(); + OpenAIEmbeddingModelConnection.ClientConfig blankConfig = + OpenAIEmbeddingModelConnection.ClientConfig.parse(blanks); + assertThat(blankConfig.baseUrl).isEqualTo(OpenAIEmbeddingModelConnection.DEFAULT_BASE_URL); + assertThat(blankConfig.organization).isNull(); + } + + @Test + @DisplayName("Missing api_key is rejected at construction") + void testMissingApiKey() { + ResourceDescriptor desc = + ResourceDescriptor.Builder.newBuilder( + OpenAIEmbeddingModelConnection.class.getName()) + .build(); + assertThatThrownBy(() -> new OpenAIEmbeddingModelConnection(desc, NOOP)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("api_key"); + } + + @Test + @DisplayName( + "request_timeout and max_retries follow the OpenAI chat connection's parsing rules") + void testNumericOptionParsing() { + assertThat(OpenAIEmbeddingModelConnection.parseRequestTimeout(null)) + .isEqualTo(Duration.ofSeconds(30)); + assertThat(OpenAIEmbeddingModelConnection.parseRequestTimeout(1.5)) + .isEqualTo(Duration.ofMillis(1500)); + // sub-millisecond values round up instead of collapsing to "disabled" + assertThat(OpenAIEmbeddingModelConnection.parseRequestTimeout(0.0001)) + .isEqualTo(Duration.ofMillis(1)); + assertThat(OpenAIEmbeddingModelConnection.parseRequestTimeout(0)).isEqualTo(Duration.ZERO); + assertThatThrownBy(() -> OpenAIEmbeddingModelConnection.parseRequestTimeout(-1)) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> OpenAIEmbeddingModelConnection.parseRequestTimeout(2147483.648)) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> OpenAIEmbeddingModelConnection.parseRequestTimeout("30")) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> OpenAIEmbeddingModelConnection.parseRequestTimeout(Double.NaN)) + .isInstanceOf(IllegalArgumentException.class); + assertThat(OpenAIEmbeddingModelConnection.parseMaxRetries(null)).isEqualTo(3); + assertThat(OpenAIEmbeddingModelConnection.parseMaxRetries(7L)).isEqualTo(7); + assertThat(OpenAIEmbeddingModelConnection.parseMaxRetries(2.0)).isEqualTo(2); + assertThatThrownBy(() -> OpenAIEmbeddingModelConnection.parseMaxRetries(1.5)) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> OpenAIEmbeddingModelConnection.parseMaxRetries(-1)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("dimensions must be a positive integer within int range, also per call") + void testDimensionsValidation() { + assertThat(OpenAIEmbeddingModelConnection.parseDimensions(256L)).isEqualTo(256); + assertThat(OpenAIEmbeddingModelConnection.parseDimensions(256.0)).isEqualTo(256); + for (Object bad : new Object[] {256.7, 0, -1, 4294967552L, 1e10, "256"}) { + assertThatThrownBy(() -> OpenAIEmbeddingModelConnection.parseDimensions(bad)) + .as("dimensions=" + bad) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("dimensions"); + } + // per-call overrides go through the same rules as the setup + OpenAIClient client = mock(OpenAIClient.class); + EmbeddingService service = mock(EmbeddingService.class); + OpenAIEmbeddingModelConnection conn = connectionWith(client, service); + assertThatThrownBy( + () -> + conn.embed( + "hello", + Map.of( + "model", + "text-embedding-3-small", + "dimensions", + 256.7))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("dimensions"); + } + + @Test + @DisplayName("Setup getParameters mirrors the Python model_kwargs") + void testSetupParameters() { + ResourceDescriptor desc = + ResourceDescriptor.Builder.newBuilder(OpenAIEmbeddingModelSetup.class.getName()) + .addInitialArgument("connection", "conn") + .addInitialArgument("model", "text-embedding-3-small") + .addInitialArgument("dimensions", 256) + .addInitialArgument("user", "user-42") + .addInitialArgument("additional_kwargs", Map.of("custom", "x")) + .build(); + OpenAIEmbeddingModelSetup setup = new OpenAIEmbeddingModelSetup(desc, NOOP); + assertThat(setup).isInstanceOf(BaseEmbeddingModelSetup.class); + assertThat(setup.getParameters()) + .containsEntry("model", "text-embedding-3-small") + .containsEntry("encoding_format", "float") + .containsEntry("dimensions", 256) + .containsEntry("user", "user-42") + .containsEntry("additional_kwargs", Map.of("custom", "x")); + } + + @Test + @DisplayName("Setup omits optional parameters that were not set") + void testSetupParametersDefaults() { + ResourceDescriptor desc = + ResourceDescriptor.Builder.newBuilder(OpenAIEmbeddingModelSetup.class.getName()) + .addInitialArgument("connection", "conn") + .addInitialArgument("model", "text-embedding-3-small") + .build(); + Map<String, Object> params = new OpenAIEmbeddingModelSetup(desc, NOOP).getParameters(); + assertThat(params) + .containsOnlyKeys("model", "encoding_format") + .containsEntry("encoding_format", "float"); + ResourceDescriptor blankUser = + ResourceDescriptor.Builder.newBuilder(OpenAIEmbeddingModelSetup.class.getName()) + .addInitialArgument("connection", "conn") + .addInitialArgument("model", "text-embedding-3-small") + .addInitialArgument("user", " ") + .build(); + // Blank strings are treated as absent, so a blank user is not emitted. + assertThat(new OpenAIEmbeddingModelSetup(blankUser, NOOP).getParameters()) + .doesNotContainKey("user"); + } + + @Test + @DisplayName("Single embedding returns the vector and token usage") + void testSingleEmbedding() { + OpenAIClient client = mock(OpenAIClient.class); + EmbeddingService service = mock(EmbeddingService.class); + when(service.create(any(EmbeddingCreateParams.class))) + .thenReturn(response(5, embedding(0, 0.1f, 0.2f, 0.3f))); + OpenAIEmbeddingModelConnection conn = connectionWith(client, service); + + EmbeddingResult<float[]> result = + conn.embedWithUsage("hello", Map.of("model", "text-embedding-3-small")); + + assertThat(result.getEmbeddings()).containsExactly(0.1f, 0.2f, 0.3f); + assertThat(result.getTokenUsage()).isNotNull(); + assertThat(result.getTokenUsage().getPromptTokens()).isEqualTo(5L); + assertThat(result.getTokenUsage().getTotalTokens()).isEqualTo(5L); + assertThat(conn.embed("hello", Map.of("model", "text-embedding-3-small"))) + .containsExactly(0.1f, 0.2f, 0.3f); + } + + @Test + @DisplayName("Batch embedding is one request and preserves input order") + void testBatchEmbeddingOrderAndSingleRequest() { + OpenAIClient client = mock(OpenAIClient.class); + EmbeddingService service = mock(EmbeddingService.class); + // Return the vectors out of order to prove the index is honored. + when(service.create(any(EmbeddingCreateParams.class))) + .thenReturn(response(9, embedding(1, 0.9f), embedding(0, 0.1f))); + OpenAIEmbeddingModelConnection conn = connectionWith(client, service); + + EmbeddingResult<List<float[]>> result = + conn.embedWithUsage( + List.of("first", "second"), Map.of("model", "text-embedding-3-small")); + + assertThat(result.getEmbeddings()).hasSize(2); + assertThat(result.getEmbeddings().get(0)).containsExactly(0.1f); + assertThat(result.getEmbeddings().get(1)).containsExactly(0.9f); + assertThat(result.getTokenUsage().getPromptTokens()).isEqualTo(9L); + verify(service).create(any(EmbeddingCreateParams.class)); + } + + @Test + @DisplayName("Setup parameters are forwarded to the request, extras as body properties") + void testRequestParametersForwarded() { + OpenAIClient client = mock(OpenAIClient.class); + EmbeddingService service = mock(EmbeddingService.class); + when(service.create(any(EmbeddingCreateParams.class))) + .thenReturn(response(1, embedding(0, 1f))); + OpenAIEmbeddingModelConnection conn = connectionWith(client, service); + + conn.embed( + "hello", + Map.of( + "model", + "text-embedding-3-large", + "encoding_format", + "base64", + "dimensions", + 512, + "user", + "u1", + "timeout", + 5000, + "additional_kwargs", + Map.of("custom_flag", true))); + + ArgumentCaptor<EmbeddingCreateParams> captor = + ArgumentCaptor.forClass(EmbeddingCreateParams.class); + verify(service).create(captor.capture()); + EmbeddingCreateParams params = captor.getValue(); + assertThat(params.model().toString()).isEqualTo("text-embedding-3-large"); + assertThat(params.encodingFormat()).contains(EmbeddingCreateParams.EncodingFormat.BASE64); + assertThat(params.dimensions()).contains(512L); + assertThat(params.user()).contains("u1"); + assertThat(params._additionalBodyProperties()) + .containsKey("custom_flag") + .doesNotContainKey("timeout"); + } + + @Test + @DisplayName("A mismatched number of embeddings fails loudly") + void testMismatchedResponseFails() { + OpenAIClient client = mock(OpenAIClient.class); + EmbeddingService service = mock(EmbeddingService.class); + when(service.create(any(EmbeddingCreateParams.class))) + .thenReturn(response(1, embedding(0, 1f))); + OpenAIEmbeddingModelConnection conn = connectionWith(client, service); + + assertThatThrownBy( + () -> + conn.embed( + List.of("a", "b"), + Map.of("model", "text-embedding-3-small"))) + .isInstanceOf(RuntimeException.class) + .hasMessageContaining("2 input texts"); + } + + @Test + @DisplayName("Missing model parameter is rejected before any request") + void testMissingModel() { + OpenAIClient client = mock(OpenAIClient.class); + EmbeddingService service = mock(EmbeddingService.class); + OpenAIEmbeddingModelConnection conn = connectionWith(client, service); + assertThatThrownBy(() -> conn.embed("hello", Map.of())) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("model"); + } + + @Test + @DisplayName("Setup validates argument types and requires a model") + void testSetupValidation() { + ResourceDescriptor noModel = + ResourceDescriptor.Builder.newBuilder(OpenAIEmbeddingModelSetup.class.getName()) + .addInitialArgument("connection", "conn") + .build(); + assertThatThrownBy(() -> new OpenAIEmbeddingModelSetup(noModel, NOOP)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("model"); + ResourceDescriptor stringDims = + ResourceDescriptor.Builder.newBuilder(OpenAIEmbeddingModelSetup.class.getName()) + .addInitialArgument("connection", "conn") + .addInitialArgument("model", "text-embedding-3-small") + .addInitialArgument("dimensions", "256") + .build(); + assertThatThrownBy(() -> new OpenAIEmbeddingModelSetup(stringDims, NOOP)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("dimensions"); + ResourceDescriptor badExtra = + ResourceDescriptor.Builder.newBuilder(OpenAIEmbeddingModelSetup.class.getName()) + .addInitialArgument("connection", "conn") + .addInitialArgument("model", "text-embedding-3-small") + .addInitialArgument("additional_kwargs", "not-a-map") + .build(); + assertThatThrownBy(() -> new OpenAIEmbeddingModelSetup(badExtra, NOOP)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("additional_kwargs"); + ResourceDescriptor unserializable = + ResourceDescriptor.Builder.newBuilder(OpenAIEmbeddingModelSetup.class.getName()) + .addInitialArgument("connection", "conn") + .addInitialArgument("model", "text-embedding-3-small") + .addInitialArgument("additional_kwargs", Map.of("opts", new Object())) + .build(); + assertThatThrownBy(() -> new OpenAIEmbeddingModelSetup(unserializable, NOOP)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("cannot be sent as JSON"); + } + + @Test + @DisplayName("A response without usage yields embeddings and null token usage") + void testMissingUsageIsTolerated() { + OpenAIClient client = mock(OpenAIClient.class); + EmbeddingService service = mock(EmbeddingService.class); + // The SDK builder refuses a response without usage, so mock the parsed response instead. + CreateEmbeddingResponse noUsage = mock(CreateEmbeddingResponse.class); + when(noUsage._data()).thenReturn(JsonField.of(List.of(embedding(0, 0.5f)))); + @SuppressWarnings("unchecked") + JsonField<CreateEmbeddingResponse.Usage> missing = + (JsonField<CreateEmbeddingResponse.Usage>) (JsonField<?>) JsonMissing.of(); + when(noUsage._usage()).thenReturn(missing); + when(service.create(any(EmbeddingCreateParams.class))).thenReturn(noUsage); + OpenAIEmbeddingModelConnection conn = connectionWith(client, service); + + EmbeddingResult<float[]> result = + conn.embedWithUsage("hello", Map.of("model", "text-embedding-3-small")); + assertThat(result.getEmbeddings()).containsExactly(0.5f); + assertThat(result.getTokenUsage()).isNull(); + } + + @Test + @DisplayName("A null input text is rejected with its index before any request") + void testNullTextRejected() { + OpenAIClient client = mock(OpenAIClient.class); + EmbeddingService service = mock(EmbeddingService.class); + OpenAIEmbeddingModelConnection conn = connectionWith(client, service); + List<String> texts = new java.util.ArrayList<>(); + texts.add("ok"); + texts.add(null); + assertThatThrownBy(() -> conn.embed(texts, Map.of("model", "text-embedding-3-small"))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("index 1"); + } + + @Test + @DisplayName("Partial usage and missing indices from compatible servers are tolerated") + void testPartialUsageAndMissingIndex() { + OpenAIClient client = mock(OpenAIClient.class); + EmbeddingService service = mock(EmbeddingService.class); + @SuppressWarnings("unchecked") + JsonField<Long> missingLong = (JsonField<Long>) (JsonField<?>) JsonMissing.of(); + CreateEmbeddingResponse.Usage usage = mock(CreateEmbeddingResponse.Usage.class); + when(usage._promptTokens()).thenReturn(missingLong); + when(usage._totalTokens()).thenReturn(JsonField.of(7L)); + Embedding first = mock(Embedding.class); + when(first._index()).thenReturn(missingLong); + when(first.embeddingValue()).thenReturn(EmbeddingValue.ofFloats(List.of(0.1f))); + Embedding second = mock(Embedding.class); + when(second._index()).thenReturn(missingLong); + when(second.embeddingValue()).thenReturn(EmbeddingValue.ofFloats(List.of(0.2f))); + CreateEmbeddingResponse response = mock(CreateEmbeddingResponse.class); + when(response._data()).thenReturn(JsonField.of(List.of(first, second))); + when(response._usage()).thenReturn(JsonField.of(usage)); + when(service.create(any(EmbeddingCreateParams.class))).thenReturn(response); + OpenAIEmbeddingModelConnection conn = connectionWith(client, service); + + EmbeddingResult<List<float[]>> result = + conn.embedWithUsage(List.of("a", "b"), Map.of("model", "text-embedding-3-small")); + + assertThat(result.getEmbeddings().get(0)).containsExactly(0.1f); + assertThat(result.getEmbeddings().get(1)).containsExactly(0.2f); + assertThat(result.getTokenUsage()).isNotNull(); + // Missing prompt_tokens equals total_tokens for embedding requests, not zero. + assertThat(result.getTokenUsage().getPromptTokens()).isEqualTo(7L); + assertThat(result.getTokenUsage().getTotalTokens()).isEqualTo(7L); + } + + @Test + @DisplayName("additional_kwargs may not repeat the typed request fields") + void testReservedAdditionalKwargsRejected() { + ResourceDescriptor setup = + ResourceDescriptor.Builder.newBuilder(OpenAIEmbeddingModelSetup.class.getName()) + .addInitialArgument("connection", "conn") + .addInitialArgument("model", "text-embedding-3-small") + .addInitialArgument( + "additional_kwargs", Map.of("model", "other", "custom", 1)) + .build(); + assertThatThrownBy(() -> new OpenAIEmbeddingModelSetup(setup, NOOP)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("additional_kwargs") + .hasMessageContaining("[model]"); + + OpenAIClient client = mock(OpenAIClient.class); + EmbeddingService service = mock(EmbeddingService.class); + OpenAIEmbeddingModelConnection conn = connectionWith(client, service); + assertThatThrownBy( + () -> + conn.embed( + "hello", + Map.of( + "model", + "text-embedding-3-small", + "additional_kwargs", + Map.of("input", "x", "dimensions", 1)))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("[dimensions, input]"); + verify(service, never()).create(any(EmbeddingCreateParams.class)); + } + + @Test + @DisplayName("encoding_format accepts only float and base64") + void testEncodingFormatValidation() { + ResourceDescriptor setup = + ResourceDescriptor.Builder.newBuilder(OpenAIEmbeddingModelSetup.class.getName()) + .addInitialArgument("connection", "conn") + .addInitialArgument("model", "text-embedding-3-small") + .addInitialArgument("encoding_format", "hex") + .build(); + assertThatThrownBy(() -> new OpenAIEmbeddingModelSetup(setup, NOOP)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("encoding_format") + .hasMessageContaining("hex"); + assertThat(OpenAIEmbeddingModelConnection.parseEncodingFormat("base64")) + .isEqualTo("base64"); + assertThat(OpenAIEmbeddingModelConnection.parseEncodingFormat(null)).isEqualTo("float"); + + OpenAIClient client = mock(OpenAIClient.class); + EmbeddingService service = mock(EmbeddingService.class); + OpenAIEmbeddingModelConnection conn = connectionWith(client, service); + assertThatThrownBy( + () -> + conn.embed( + "hello", + Map.of( + "model", + "text-embedding-3-small", + "encoding_format", + "FLOAT"))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("encoding_format"); + verify(service, never()).create(any(EmbeddingCreateParams.class)); + } + + @Test + @DisplayName("Non-string or missing connection/model fail with named errors, not a CCE") + void testSetupConnectionAndModelValidation() { + ResourceDescriptor intModel = + ResourceDescriptor.Builder.newBuilder(OpenAIEmbeddingModelSetup.class.getName()) + .addInitialArgument("connection", "conn") + .addInitialArgument("model", 42) + .build(); + assertThatThrownBy(() -> new OpenAIEmbeddingModelSetup(intModel, NOOP)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("model must be a string"); + ResourceDescriptor intConnection = + ResourceDescriptor.Builder.newBuilder(OpenAIEmbeddingModelSetup.class.getName()) + .addInitialArgument("connection", 42) + .addInitialArgument("model", "text-embedding-3-small") + .build(); + assertThatThrownBy(() -> new OpenAIEmbeddingModelSetup(intConnection, NOOP)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("connection must be a string"); + ResourceDescriptor noConnection = + ResourceDescriptor.Builder.newBuilder(OpenAIEmbeddingModelSetup.class.getName()) + .addInitialArgument("model", "text-embedding-3-small") + .build(); + assertThatThrownBy(() -> new OpenAIEmbeddingModelSetup(noConnection, NOOP)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("non-empty 'connection'"); + } + + private static Embedding indexlessEmbedding(float value) { + @SuppressWarnings("unchecked") + JsonField<Long> missingLong = (JsonField<Long>) (JsonField<?>) JsonMissing.of(); + Embedding embedding = mock(Embedding.class); + when(embedding._index()).thenReturn(missingLong); + when(embedding.embeddingValue()).thenReturn(EmbeddingValue.ofFloats(List.of(value))); + return embedding; + } + + private static Embedding indexedMock(long index, float value) { + Embedding embedding = mock(Embedding.class); + when(embedding._index()).thenReturn(JsonField.of(index)); + when(embedding.embeddingValue()).thenReturn(EmbeddingValue.ofFloats(List.of(value))); + return embedding; + } + + @SuppressWarnings("unchecked") + private static CreateEmbeddingResponse responseWithoutUsage(Embedding... data) { + CreateEmbeddingResponse response = mock(CreateEmbeddingResponse.class); + when(response._data()).thenReturn(JsonField.of(List.of(data))); + when(response._usage()) + .thenReturn( + (JsonField<CreateEmbeddingResponse.Usage>) (JsonField<?>) JsonMissing.of()); + return response; + } + + @Test + @DisplayName("A partially indexed response is accepted only if its indices match positions") + void testPartiallyIndexedResponse() { + OpenAIClient client = mock(OpenAIClient.class); + EmbeddingService service = mock(EmbeddingService.class); + CreateEmbeddingResponse response = + responseWithoutUsage(indexlessEmbedding(0.1f), indexedMock(1, 0.2f)); + when(service.create(any(EmbeddingCreateParams.class))).thenReturn(response); + OpenAIEmbeddingModelConnection conn = connectionWith(client, service); + + List<float[]> embeddings = + conn.embed(List.of("a", "b"), Map.of("model", "text-embedding-3-small")); + + assertThat(embeddings.get(0)).containsExactly(0.1f); + assertThat(embeddings.get(1)).containsExactly(0.2f); + } + + @Test + @DisplayName("A partially indexed response that contradicts response order is rejected") + void testContradictoryPartialIndexRejected() { + OpenAIClient client = mock(OpenAIClient.class); + EmbeddingService service = mock(EmbeddingService.class); + // Vector 0.2 claims index 0 while sitting at position 1: neither order can be trusted. + CreateEmbeddingResponse response = + responseWithoutUsage(indexlessEmbedding(0.1f), indexedMock(0, 0.2f)); + when(service.create(any(EmbeddingCreateParams.class))).thenReturn(response); + OpenAIEmbeddingModelConnection conn = connectionWith(client, service); + + assertThatThrownBy( + () -> + conn.embed( + List.of("a", "b"), + Map.of("model", "text-embedding-3-small"))) + .isInstanceOf(RuntimeException.class) + .hasMessageContaining("unexpected embedding index 0 at position 1"); + } + + @Test + @DisplayName("An empty batch is validated but sends no request") + void testEmptyBatchValidatesWithoutRequest() { + OpenAIClient client = mock(OpenAIClient.class); + EmbeddingService service = mock(EmbeddingService.class); + OpenAIEmbeddingModelConnection conn = connectionWith(client, service); + + assertThat(conn.embed(List.of(), Map.of("model", "text-embedding-3-small"))).isEmpty(); + assertThatThrownBy( + () -> + conn.embed( + (List<String>) null, + Map.of("model", "text-embedding-3-small"))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("texts must not be null"); + // A null parameter map is treated as empty, so the missing model is the reported error. + assertThatThrownBy(() -> conn.embed("hello", null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("model"); + assertThatThrownBy( + () -> + conn.embed( + List.of(), + Map.of( + "model", + "text-embedding-3-small", + "encoding_format", + "bogus"))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("encoding_format"); + verify(service, never()).create(any(EmbeddingCreateParams.class)); + } + + @Test + @DisplayName("A base64 response is decoded to floats") + void testBase64ResponseDecoded() { + OpenAIClient client = mock(OpenAIClient.class); + EmbeddingService service = mock(EmbeddingService.class); + // 0.5f and 0.25f as little-endian float32, base64 encoded (the OpenAI wire format). + String base64 = + Base64.getEncoder() + .encodeToString( + ByteBuffer.allocate(8) + .order(ByteOrder.LITTLE_ENDIAN) + .putFloat(0.5f) + .putFloat(0.25f) + .array()); + Embedding encoded = + Embedding.builder() + .index(0L) + .object_(JsonValue.from("embedding")) + .embedding(base64) + .build(); + CreateEmbeddingResponse response = response(3, encoded); + when(service.create(any(EmbeddingCreateParams.class))).thenReturn(response); + OpenAIEmbeddingModelConnection conn = connectionWith(client, service); + + float[] vector = + conn.embed( + "hello", + Map.of("model", "text-embedding-3-small", "encoding_format", "base64")); + + assertThat(vector).containsExactly(0.5f, 0.25f); + } + + @Test + @DisplayName( + "A base64 vector whose length is not a multiple of four is rejected, not truncated") + void testTruncatedBase64Rejected() { + OpenAIClient client = mock(OpenAIClient.class); + EmbeddingService service = mock(EmbeddingService.class); + // 6 bytes: one float plus two trailing bytes; the SDK would silently return [1.0]. + Embedding truncated = + Embedding.builder() + .index(0L) + .object_(JsonValue.from("embedding")) + .embedding("AACAPwAA") + .build(); + when(service.create(any(EmbeddingCreateParams.class))).thenReturn(response(3, truncated)); + OpenAIEmbeddingModelConnection conn = connectionWith(client, service); + + assertThatThrownBy( + () -> + conn.embed( + "hello", + Map.of( + "model", + "text-embedding-3-small", + "encoding_format", + "base64"))) + .isInstanceOf(RuntimeException.class) + .hasMessageContaining("malformed embedding at position 0") + .hasRootCauseMessage("base64 embedding has 6 bytes, not a multiple of 4."); + } + + @Test + @DisplayName("Decimal-string token counts from compatible servers are accepted") + void testStringTokenCounts() { + OpenAIClient client = mock(OpenAIClient.class); + EmbeddingService service = mock(EmbeddingService.class); + @SuppressWarnings("unchecked") + JsonField<Long> five = (JsonField<Long>) (JsonField<?>) JsonValue.from("5"); + @SuppressWarnings("unchecked") + JsonField<Long> junk = (JsonField<Long>) (JsonField<?>) JsonValue.from("n/a"); + CreateEmbeddingResponse.Usage usage = mock(CreateEmbeddingResponse.Usage.class); + when(usage._promptTokens()).thenReturn(five); + when(usage._totalTokens()).thenReturn(junk); + CreateEmbeddingResponse response = mock(CreateEmbeddingResponse.class); + when(response._data()).thenReturn(JsonField.of(List.of(embedding(0, 0.1f)))); + when(response._usage()).thenReturn(JsonField.of(usage)); + when(service.create(any(EmbeddingCreateParams.class))).thenReturn(response); + OpenAIEmbeddingModelConnection conn = connectionWith(client, service); + + EmbeddingResult<float[]> result = + conn.embedWithUsage("a", Map.of("model", "text-embedding-3-small")); + + assertThat(result.getTokenUsage()).isNotNull(); + assertThat(result.getTokenUsage().getPromptTokens()).isEqualTo(5L); + assertThat(result.getTokenUsage().getTotalTokens()).isEqualTo(5L); + } + + @Test + @DisplayName("A response without data is reported as a short response") + void testMissingDataReported() { + OpenAIClient client = mock(OpenAIClient.class); + EmbeddingService service = mock(EmbeddingService.class); + @SuppressWarnings("unchecked") + JsonField<List<Embedding>> missingData = + (JsonField<List<Embedding>>) (JsonField<?>) JsonMissing.of(); + CreateEmbeddingResponse response = mock(CreateEmbeddingResponse.class); + when(response._data()).thenReturn(missingData); + when(service.create(any(EmbeddingCreateParams.class))).thenReturn(response); + OpenAIEmbeddingModelConnection conn = connectionWith(client, service); + + assertThatThrownBy( + () -> + conn.embed( + List.of("a", "b"), + Map.of("model", "text-embedding-3-small"))) + .isInstanceOf(RuntimeException.class) + .hasMessageContaining("returned 0 embeddings for 2 input texts"); + + // A data field that is present but not a list of embeddings names the payload. + @SuppressWarnings("unchecked") + JsonField<List<Embedding>> objectData = + (JsonField<List<Embedding>>) (JsonField<?>) JsonValue.from(Map.of("0", 1)); + CreateEmbeddingResponse badData = mock(CreateEmbeddingResponse.class); + when(badData._data()).thenReturn(objectData); + when(service.create(any(EmbeddingCreateParams.class))).thenReturn(badData); + assertThatThrownBy(() -> conn.embed("a", Map.of("model", "text-embedding-3-small"))) + .isInstanceOf(RuntimeException.class) + .hasMessageContaining("malformed data field"); + + // A string vector when float was requested is malformed, not silently decoded. + Embedding stringVector = + Embedding.builder() + .index(0L) + .object_(JsonValue.from("embedding")) + .embedding("AAAAAAAAAAA=") + .build(); + when(service.create(any(EmbeddingCreateParams.class))) + .thenReturn(response(3, stringVector)); + assertThatThrownBy(() -> conn.embed("a", Map.of("model", "text-embedding-3-small"))) + .isInstanceOf(RuntimeException.class) + .hasMessageContaining("malformed embedding at position 0") + .hasRootCauseMessage( + "a string vector was returned although encoding_format was float."); + + // A null element in data is a malformed embedding at its position. + CreateEmbeddingResponse nullItem = mock(CreateEmbeddingResponse.class); + when(nullItem._data()).thenReturn(JsonField.of(java.util.Arrays.asList((Embedding) null))); + when(service.create(any(EmbeddingCreateParams.class))).thenReturn(nullItem); + assertThatThrownBy(() -> conn.embed("a", Map.of("model", "text-embedding-3-small"))) + .isInstanceOf(RuntimeException.class) + .hasMessageContaining("malformed embedding at position 0"); + + OpenAIClient client2 = mock(OpenAIClient.class); + EmbeddingService service2 = mock(EmbeddingService.class); + OpenAIEmbeddingModelConnection conn2 = connectionWith(client2, service2); + // A per-call additional_kwargs value the SDK cannot serialize names its key. + assertThatThrownBy( + () -> + conn2.embed( + "hello", + Map.of( + "model", + "text-embedding-3-small", + "additional_kwargs", + Map.of("meta", new Object())))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("value for key 'meta' cannot be sent as JSON"); + } + + @Test + @DisplayName("A null or undecodable vector is reported with its position") + void testMalformedVectorReported() { + OpenAIClient client = mock(OpenAIClient.class); + EmbeddingService service = mock(EmbeddingService.class); + Embedding broken = mock(Embedding.class); + when(broken._index()).thenReturn(JsonField.of(1L)); + when(broken.embeddingValue()) + .thenThrow(new IllegalStateException("Invalid EmbeddingValue")); + CreateEmbeddingResponse response = responseWithoutUsage(indexedMock(0, 0.1f), broken); + when(service.create(any(EmbeddingCreateParams.class))).thenReturn(response); + OpenAIEmbeddingModelConnection conn = connectionWith(client, service); + + assertThatThrownBy( + () -> + conn.embed( + List.of("a", "b"), + Map.of("model", "text-embedding-3-small"))) + .isInstanceOf(RuntimeException.class) + .hasMessageContaining("malformed embedding at position 1") + .hasCauseInstanceOf(IllegalStateException.class); + } + + @Test + @DisplayName("A non-integer index is a malformed response, not an absent index") + void testNonIntegerIndexRejected() { + OpenAIClient client = mock(OpenAIClient.class); + EmbeddingService service = mock(EmbeddingService.class); + // A JSON string where a long is expected: present, not null, but not a known Long. + @SuppressWarnings("unchecked") + JsonField<Long> stringIndex = (JsonField<Long>) (JsonField<?>) JsonValue.from("1"); + Embedding malformed = mock(Embedding.class); + when(malformed._index()).thenReturn(stringIndex); + when(malformed.embeddingValue()).thenReturn(EmbeddingValue.ofFloats(List.of(0.9f))); + CreateEmbeddingResponse response = responseWithoutUsage(malformed, indexedMock(0, 0.1f)); + when(service.create(any(EmbeddingCreateParams.class))).thenReturn(response); + OpenAIEmbeddingModelConnection conn = connectionWith(client, service); + + assertThatThrownBy( + () -> + conn.embed( + List.of("a", "b"), + Map.of("model", "text-embedding-3-small"))) + .isInstanceOf(RuntimeException.class) + .hasMessageContaining("non-integer embedding index"); + } + + @Test + @DisplayName("Indices outside the batch fail with a clear error") + void testOutOfRangeIndexFails() { + OpenAIClient client = mock(OpenAIClient.class); + EmbeddingService service = mock(EmbeddingService.class); + when(service.create(any(EmbeddingCreateParams.class))) + .thenReturn(response(3, embedding(0, 0.1f), embedding(Long.MAX_VALUE, 0.2f))); + OpenAIEmbeddingModelConnection conn = connectionWith(client, service); + + assertThatThrownBy( + () -> + conn.embed( + List.of("a", "b"), + Map.of("model", "text-embedding-3-small"))) + .isInstanceOf(RuntimeException.class) + .hasMessageContaining("unexpected embedding index " + Long.MAX_VALUE); + } +} diff --git a/integrations/embedding-models/pom.xml b/integrations/embedding-models/pom.xml index 2a585d17..19325c07 100644 --- a/integrations/embedding-models/pom.xml +++ b/integrations/embedding-models/pom.xml @@ -33,6 +33,7 @@ under the License. <modules> <module>bedrock</module> <module>ollama</module> + <module>openai</module> </modules> </project> diff --git a/python/flink_agents/api/resource.py b/python/flink_agents/api/resource.py index 55c6ee12..8cac7112 100644 --- a/python/flink_agents/api/resource.py +++ b/python/flink_agents/api/resource.py @@ -366,6 +366,10 @@ class ResourceName: BEDROCK_CONNECTION = "org.apache.flink.agents.integrations.embeddingmodels.bedrock.BedrockEmbeddingModelConnection" BEDROCK_SETUP = "org.apache.flink.agents.integrations.embeddingmodels.bedrock.BedrockEmbeddingModelSetup" + # OpenAI + OPENAI_CONNECTION = "org.apache.flink.agents.integrations.embeddingmodels.openai.OpenAIEmbeddingModelConnection" + OPENAI_SETUP = "org.apache.flink.agents.integrations.embeddingmodels.openai.OpenAIEmbeddingModelSetup" + class VectorStore: """VectorStore resource names.""" diff --git a/python/flink_agents/api/yaml/aliases.py b/python/flink_agents/api/yaml/aliases.py index 584ba2fd..76b59ddd 100644 --- a/python/flink_agents/api/yaml/aliases.py +++ b/python/flink_agents/api/yaml/aliases.py @@ -112,6 +112,7 @@ CLAZZ_ALIASES: Dict[ResourceType, Dict[str, Dict[str, str]]] = { "java": { "ollama": ResourceName.EmbeddingModel.Java.OLLAMA_CONNECTION, "bedrock": ResourceName.EmbeddingModel.Java.BEDROCK_CONNECTION, + "openai": ResourceName.EmbeddingModel.Java.OPENAI_CONNECTION, }, }, ResourceType.EMBEDDING_MODEL: { @@ -123,6 +124,7 @@ CLAZZ_ALIASES: Dict[ResourceType, Dict[str, Dict[str, str]]] = { "java": { "ollama": ResourceName.EmbeddingModel.Java.OLLAMA_SETUP, "bedrock": ResourceName.EmbeddingModel.Java.BEDROCK_SETUP, + "openai": ResourceName.EmbeddingModel.Java.OPENAI_SETUP, }, }, ResourceType.VECTOR_STORE: { diff --git a/python/flink_agents/api/yaml/tests/test_aliases.py b/python/flink_agents/api/yaml/tests/test_aliases.py index d7ef0bc0..ecac917f 100644 --- a/python/flink_agents/api/yaml/tests/test_aliases.py +++ b/python/flink_agents/api/yaml/tests/test_aliases.py @@ -199,6 +199,23 @@ def test_resolve_clazz_covers_vector_store_java_and_python() -> None: ) +def test_resolve_clazz_covers_embedding_model_openai_in_both_languages() -> None: + assert resolve_clazz("openai", ResourceType.EMBEDDING_MODEL_CONNECTION).endswith( + "openai_embedding_model.OpenAIEmbeddingModelConnection" + ) + assert resolve_clazz("openai", ResourceType.EMBEDDING_MODEL).endswith( + "openai_embedding_model.OpenAIEmbeddingModelSetup" + ) + # The Java and Python simple names collide (OpenAIEmbeddingModelConnection); pin the + # package so a Java entry that resolved to the Python class would fail here. + java_conn = resolve_clazz("openai", ResourceType.EMBEDDING_MODEL_CONNECTION, "java") + assert java_conn.startswith("org.apache.flink.agents") + assert java_conn.endswith("OpenAIEmbeddingModelConnection") + java_setup = resolve_clazz("openai", ResourceType.EMBEDDING_MODEL, "java") + assert java_setup.startswith("org.apache.flink.agents") + assert java_setup.endswith("OpenAIEmbeddingModelSetup") + + def test_java_wrapper_clazz_table_covers_supported_types() -> None: # The Python-side wrappers must exist for every cross-language type expected = {
