From e8dc67fcf15d83364d969f586c1cd03f225542e3 Mon Sep 17 00:00:00 2001 From: xavidop Date: Mon, 6 Jul 2026 13:58:14 +0200 Subject: [PATCH 1/6] feat: more plugins --- README.md | 4 +- docs/astro.config.mjs | 3 + .../src/content/docs/agents/session-stores.md | 100 ++- docs/src/content/docs/getting-started.mdx | 2 +- docs/src/content/docs/index.mdx | 33 + docs/src/content/docs/models.mdx | 2 +- docs/src/content/docs/plugins/chroma.md | 66 ++ .../docs/plugins/firebase-functions.md | 2 +- .../docs/plugins/firebase-vector-store.md | 8 +- docs/src/content/docs/plugins/google-genai.md | 4 +- docs/src/content/docs/plugins/mongodb.md | 83 ++ .../src/content/docs/plugins/opentelemetry.md | 2 +- docs/src/content/docs/plugins/postgresql.md | 15 + docs/src/content/docs/plugins/qdrant.md | 70 ++ .../main/java/com/google/genkit/Genkit.java | 2 +- plugins/chroma/pom.xml | 78 ++ .../chroma/ChromaCollectionConfig.java | 206 +++++ .../genkit/plugins/chroma/ChromaPlugin.java | 201 +++++ .../plugins/chroma/ChromaVectorStore.java | 311 +++++++ .../genkit/plugins/chroma/package-info.java | 25 + .../chroma/ChromaCollectionConfigTest.java | 70 ++ .../plugins/chroma/ChromaPluginTest.java | 55 ++ .../plugins/chroma/ChromaVectorStoreTest.java | 111 +++ plugins/evaluators/README.md | 10 +- .../plugins/evaluators/EvaluatorsPlugin.java | 2 +- .../EvaluatorsPluginOptionsTest.java | 4 +- plugins/firebase/README.md | 8 +- .../plugins/firebase/FirebasePlugin.java | 2 +- .../retriever/FirestoreRetrieverConfig.java | 4 +- .../plugins/googlegenai/GeminiModel.java | 2 +- plugins/mongodb/pom.xml | 80 ++ .../genkit/plugins/mongodb/MongoPlugin.java | 183 +++++ .../plugins/mongodb/MongoVectorStore.java | 363 +++++++++ .../mongodb/MongoVectorStoreConfig.java | 355 ++++++++ .../genkit/plugins/mongodb/package-info.java | 26 + .../mongodb/session/MongoSessionStore.java | 723 +++++++++++++++++ .../session/MongoSessionStoreOptions.java | 249 ++++++ .../plugins/mongodb/session/package-info.java | 28 + .../plugins/mongodb/MongoPluginTest.java | 65 ++ .../mongodb/MongoVectorStoreConfigTest.java | 94 +++ .../plugins/mongodb/MongoVectorStoreTest.java | 138 ++++ .../session/MongoSessionStoreOptionsTest.java | 78 ++ .../session/MongoSessionStoreTest.java | 202 +++++ plugins/pinecone/README.md | 10 +- .../plugins/pinecone/PineconeIndexConfig.java | 2 +- .../plugins/pinecone/PineconePlugin.java | 2 +- .../genkit/plugins/pinecone/package-info.java | 2 +- plugins/postgresql/README.md | 4 +- .../plugins/postgresql/PostgresPlugin.java | 2 +- .../postgresql/PostgresTableConfig.java | 2 +- .../plugins/postgresql/package-info.java | 2 +- .../session/PostgresSessionStore.java | 761 ++++++++++++++++++ .../session/PostgresSessionStoreOptions.java | 243 ++++++ .../postgresql/session/package-info.java | 27 + .../PostgresSessionStoreOptionsTest.java | 76 ++ .../session/PostgresSessionStoreTest.java | 212 +++++ plugins/qdrant/pom.xml | 78 ++ .../qdrant/QdrantCollectionConfig.java | 258 ++++++ .../genkit/plugins/qdrant/QdrantPlugin.java | 176 ++++ .../plugins/qdrant/QdrantVectorStore.java | 333 ++++++++ .../genkit/plugins/qdrant/package-info.java | 25 + .../qdrant/QdrantCollectionConfigTest.java | 78 ++ .../plugins/qdrant/QdrantPluginTest.java | 48 ++ .../plugins/qdrant/QdrantVectorStoreTest.java | 110 +++ plugins/weaviate/README.md | 6 +- .../weaviate/WeaviateCollectionConfig.java | 4 +- .../plugins/weaviate/WeaviatePlugin.java | 2 +- .../genkit/plugins/weaviate/package-info.java | 2 +- pom.xml | 8 + samples/README.md | 5 + samples/agents-mongo-session/README.md | 65 ++ samples/agents-mongo-session/pom.xml | 87 ++ samples/agents-mongo-session/run.sh | 7 + .../genkit/samples/MongoSessionAgentApp.java | 119 +++ .../src/main/resources/logback.xml | 24 + samples/agents-postgres-session/README.md | 70 ++ samples/agents-postgres-session/pom.xml | 87 ++ samples/agents-postgres-session/run.sh | 7 + .../samples/PostgresSessionAgentApp.java | 135 ++++ .../src/main/resources/logback.xml | 24 + samples/chroma/README.md | 60 ++ samples/chroma/pom.xml | 86 ++ samples/chroma/run.sh | 7 + .../samples/chroma/ChromaRAGSample.java | 163 ++++ samples/chroma/src/main/resources/logback.xml | 20 + samples/firebase/README.md | 2 +- .../samples/firebase/FirestoreRAGSample.java | 4 +- .../functions/GeneratePoemFunction.java | 2 +- samples/google-genai/README.md | 6 +- .../google/genkit/samples/GoogleGenAIApp.java | 6 +- samples/mongo-vector/README.md | 65 ++ samples/mongo-vector/pom.xml | 86 ++ samples/mongo-vector/run.sh | 7 + .../samples/mongo/MongoVectorRAGSample.java | 171 ++++ .../src/main/resources/logback.xml | 21 + samples/pinecone/README.md | 6 +- .../samples/pinecone/PineconeRAGSample.java | 4 +- samples/postgresql/README.md | 4 +- .../samples/postgresql/PostgresRAGSample.java | 4 +- samples/qdrant/README.md | 63 ++ samples/qdrant/pom.xml | 86 ++ samples/qdrant/run.sh | 7 + .../samples/qdrant/QdrantRAGSample.java | 166 ++++ samples/qdrant/src/main/resources/logback.xml | 20 + samples/weaviate/README.md | 4 +- .../samples/weaviate/WeaviateRAGSample.java | 4 +- .../SKILL.md | 12 +- 107 files changed, 8178 insertions(+), 80 deletions(-) create mode 100644 docs/src/content/docs/plugins/chroma.md create mode 100644 docs/src/content/docs/plugins/mongodb.md create mode 100644 docs/src/content/docs/plugins/qdrant.md create mode 100644 plugins/chroma/pom.xml create mode 100644 plugins/chroma/src/main/java/com/google/genkit/plugins/chroma/ChromaCollectionConfig.java create mode 100644 plugins/chroma/src/main/java/com/google/genkit/plugins/chroma/ChromaPlugin.java create mode 100644 plugins/chroma/src/main/java/com/google/genkit/plugins/chroma/ChromaVectorStore.java create mode 100644 plugins/chroma/src/main/java/com/google/genkit/plugins/chroma/package-info.java create mode 100644 plugins/chroma/src/test/java/com/google/genkit/plugins/chroma/ChromaCollectionConfigTest.java create mode 100644 plugins/chroma/src/test/java/com/google/genkit/plugins/chroma/ChromaPluginTest.java create mode 100644 plugins/chroma/src/test/java/com/google/genkit/plugins/chroma/ChromaVectorStoreTest.java create mode 100644 plugins/mongodb/pom.xml create mode 100644 plugins/mongodb/src/main/java/com/google/genkit/plugins/mongodb/MongoPlugin.java create mode 100644 plugins/mongodb/src/main/java/com/google/genkit/plugins/mongodb/MongoVectorStore.java create mode 100644 plugins/mongodb/src/main/java/com/google/genkit/plugins/mongodb/MongoVectorStoreConfig.java create mode 100644 plugins/mongodb/src/main/java/com/google/genkit/plugins/mongodb/package-info.java create mode 100644 plugins/mongodb/src/main/java/com/google/genkit/plugins/mongodb/session/MongoSessionStore.java create mode 100644 plugins/mongodb/src/main/java/com/google/genkit/plugins/mongodb/session/MongoSessionStoreOptions.java create mode 100644 plugins/mongodb/src/main/java/com/google/genkit/plugins/mongodb/session/package-info.java create mode 100644 plugins/mongodb/src/test/java/com/google/genkit/plugins/mongodb/MongoPluginTest.java create mode 100644 plugins/mongodb/src/test/java/com/google/genkit/plugins/mongodb/MongoVectorStoreConfigTest.java create mode 100644 plugins/mongodb/src/test/java/com/google/genkit/plugins/mongodb/MongoVectorStoreTest.java create mode 100644 plugins/mongodb/src/test/java/com/google/genkit/plugins/mongodb/session/MongoSessionStoreOptionsTest.java create mode 100644 plugins/mongodb/src/test/java/com/google/genkit/plugins/mongodb/session/MongoSessionStoreTest.java create mode 100644 plugins/postgresql/src/main/java/com/google/genkit/plugins/postgresql/session/PostgresSessionStore.java create mode 100644 plugins/postgresql/src/main/java/com/google/genkit/plugins/postgresql/session/PostgresSessionStoreOptions.java create mode 100644 plugins/postgresql/src/main/java/com/google/genkit/plugins/postgresql/session/package-info.java create mode 100644 plugins/postgresql/src/test/java/com/google/genkit/plugins/postgresql/session/PostgresSessionStoreOptionsTest.java create mode 100644 plugins/postgresql/src/test/java/com/google/genkit/plugins/postgresql/session/PostgresSessionStoreTest.java create mode 100644 plugins/qdrant/pom.xml create mode 100644 plugins/qdrant/src/main/java/com/google/genkit/plugins/qdrant/QdrantCollectionConfig.java create mode 100644 plugins/qdrant/src/main/java/com/google/genkit/plugins/qdrant/QdrantPlugin.java create mode 100644 plugins/qdrant/src/main/java/com/google/genkit/plugins/qdrant/QdrantVectorStore.java create mode 100644 plugins/qdrant/src/main/java/com/google/genkit/plugins/qdrant/package-info.java create mode 100644 plugins/qdrant/src/test/java/com/google/genkit/plugins/qdrant/QdrantCollectionConfigTest.java create mode 100644 plugins/qdrant/src/test/java/com/google/genkit/plugins/qdrant/QdrantPluginTest.java create mode 100644 plugins/qdrant/src/test/java/com/google/genkit/plugins/qdrant/QdrantVectorStoreTest.java create mode 100644 samples/agents-mongo-session/README.md create mode 100644 samples/agents-mongo-session/pom.xml create mode 100755 samples/agents-mongo-session/run.sh create mode 100644 samples/agents-mongo-session/src/main/java/com/google/genkit/samples/MongoSessionAgentApp.java create mode 100644 samples/agents-mongo-session/src/main/resources/logback.xml create mode 100644 samples/agents-postgres-session/README.md create mode 100644 samples/agents-postgres-session/pom.xml create mode 100755 samples/agents-postgres-session/run.sh create mode 100644 samples/agents-postgres-session/src/main/java/com/google/genkit/samples/PostgresSessionAgentApp.java create mode 100644 samples/agents-postgres-session/src/main/resources/logback.xml create mode 100644 samples/chroma/README.md create mode 100644 samples/chroma/pom.xml create mode 100755 samples/chroma/run.sh create mode 100644 samples/chroma/src/main/java/com/google/genkit/samples/chroma/ChromaRAGSample.java create mode 100644 samples/chroma/src/main/resources/logback.xml create mode 100644 samples/mongo-vector/README.md create mode 100644 samples/mongo-vector/pom.xml create mode 100755 samples/mongo-vector/run.sh create mode 100644 samples/mongo-vector/src/main/java/com/google/genkit/samples/mongo/MongoVectorRAGSample.java create mode 100644 samples/mongo-vector/src/main/resources/logback.xml create mode 100644 samples/qdrant/README.md create mode 100644 samples/qdrant/pom.xml create mode 100755 samples/qdrant/run.sh create mode 100644 samples/qdrant/src/main/java/com/google/genkit/samples/qdrant/QdrantRAGSample.java create mode 100644 samples/qdrant/src/main/resources/logback.xml diff --git a/README.md b/README.md index 85b180b9d..d222f4b92 100644 --- a/README.md +++ b/README.md @@ -438,7 +438,7 @@ Genkit genkit = Genkit.builder() .addRetriever(FirestoreRetrieverConfig.builder() .name("my-docs") .collection("documents") - .embedderName("googleai/text-embedding-004") + .embedderName("googleai/gemini-embedding-001") .vectorField("embedding") .contentField("content") .distanceMeasure(FirestoreRetrieverConfig.DistanceMeasure.COSINE) @@ -476,7 +476,7 @@ public class MyFunction implements HttpFunction { genkit.defineFlow("generatePoem", String.class, String.class, (ctx, topic) -> { return genkit.generate(GenerateOptions.builder() - .model("googleai/gemini-2.0-flash") + .model("googleai/gemini-2.5-flash") .prompt("Write a poem about: " + topic) .build()).getText(); }); diff --git a/docs/astro.config.mjs b/docs/astro.config.mjs index e3ed7269a..9bae49eff 100644 --- a/docs/astro.config.mjs +++ b/docs/astro.config.mjs @@ -142,6 +142,9 @@ export default defineConfig({ { label: "Weaviate", slug: "plugins/weaviate" }, { label: "PostgreSQL (pgvector)", slug: "plugins/postgresql" }, { label: "Pinecone", slug: "plugins/pinecone" }, + { label: "Chroma", slug: "plugins/chroma" }, + { label: "Qdrant", slug: "plugins/qdrant" }, + { label: "MongoDB", slug: "plugins/mongodb" }, ], }, { diff --git a/docs/src/content/docs/agents/session-stores.md b/docs/src/content/docs/agents/session-stores.md index a8b721e3f..4ecf3d3fc 100644 --- a/docs/src/content/docs/agents/session-stores.md +++ b/docs/src/content/docs/agents/session-stores.md @@ -1,6 +1,6 @@ --- title: Session Stores -description: Persist agent session state with in-memory, file-based, Firestore, DynamoDB, or Cosmos DB session stores. +description: Persist agent session state with in-memory, file-based, Firestore, DynamoDB, Cosmos DB, PostgreSQL, or MongoDB session stores. --- A `SessionStore` is where a server-managed agent keeps its sessions. Pass one to `.store(...)` when you define an agent to persist snapshots; omit it to run the agent client-managed (stateless). Several implementations ship out of the box, and you can write your own. @@ -12,8 +12,10 @@ A `SessionStore` is where a server-managed agent keeps its sessions. Pass one | `FirestoreSessionStore` | Google Cloud Firestore | Production on Google Cloud (Cloud Run, Firebase Functions) | | `DynamoDbSessionStore` | Amazon DynamoDB | Production on AWS | | `CosmosSessionStore` | Azure Cosmos DB | Production on Azure | +| `PostgresSessionStore` | PostgreSQL | Production on any SQL-first stack | +| `MongoSessionStore` | MongoDB | Production on any document-first stack | -If you plan to use `chat.abort()` or the `/abort` HTTP endpoint, pick a store that supports change notifications: `FileSessionStore`, `FirestoreSessionStore`, `DynamoDbSessionStore`, and `CosmosSessionStore` do; `InMemorySessionStore` does not, so `abort()` is a no-op there. See [Sessions](../sessions#aborting-a-turn). +If you plan to use `chat.abort()` or the `/abort` HTTP endpoint, pick a store that supports change notifications: `FileSessionStore`, `FirestoreSessionStore`, `DynamoDbSessionStore`, `CosmosSessionStore`, `PostgresSessionStore`, and `MongoSessionStore` do; `InMemorySessionStore` does not, so `abort()` is a no-op there. See [Sessions](../sessions#aborting-a-turn). ## InMemorySessionStore @@ -199,6 +201,100 @@ CosmosSessionStore> store = The default shard size is 1 MiB, kept under Cosmos DB's 2 MB document-size limit. `CosmosSessionStore` supports `onSnapshotStateChange` (via polling), so `chat.abort()` works. +## PostgresSessionStore + +Stores snapshots in PostgreSQL. It lives in the PostgreSQL plugin, so add the dependency: + +```xml + + com.google.genkit + genkit-plugin-postgresql + ${genkit.version} + +``` + +```java +import com.google.genkit.plugins.postgresql.session.PostgresSessionStore; +import com.google.genkit.plugins.postgresql.session.PostgresSessionStoreOptions; +import org.postgresql.ds.PGSimpleDataSource; + +PGSimpleDataSource dataSource = new PGSimpleDataSource(); +dataSource.setUrl("jdbc:postgresql://localhost:5432/genkit"); +dataSource.setUser("postgres"); +dataSource.setPassword("postgres"); + +PostgresSessionStore> store = + new PostgresSessionStore<>(dataSource); // uses the default "genkit_sessions" table + +Agent> agent = genkit.beta().defineAgent( + AgentConfig.>builder() + .name("myAgent") + .system("You are a helpful assistant.") + .store(store) + .build()); +``` + +All records live in a single table keyed by `(pk, id)` with a JSONB `doc` payload and a `version` column for optimistic concurrency. Create the table ahead of time, or let the store create it on first use: + +```java +PostgresSessionStore> store = + new PostgresSessionStore<>( + dataSource, + PostgresSessionStoreOptions.builder() + .tableName("genkit_sessions") // table name (default "genkit_sessions") + .createTableIfNotExists(true) // auto-create the table on first use (default false) + .pollIntervalMs(500) // how often change subscribers are polled (default 2000) + .build()); +``` + +The default shard size is 1 MiB. `PostgresSessionStore` supports `onSnapshotStateChange` (via polling), so `chat.abort()` works. + +## MongoSessionStore + +Stores snapshots in MongoDB. It lives in the MongoDB plugin, so add the dependency: + +```xml + + com.google.genkit + genkit-plugin-mongodb + ${genkit.version} + +``` + +```java +import com.google.genkit.plugins.mongodb.session.MongoSessionStore; +import com.google.genkit.plugins.mongodb.session.MongoSessionStoreOptions; +import com.mongodb.client.MongoClient; +import com.mongodb.client.MongoClients; + +MongoClient client = MongoClients.create("mongodb://localhost:27017"); + +MongoSessionStore> store = + new MongoSessionStore<>(client); // uses database "genkit", collection "genkit_sessions" + +Agent> agent = genkit.beta().defineAgent( + AgentConfig.>builder() + .name("myAgent") + .system("You are a helpful assistant.") + .store(store) + .build()); +``` + +All records live in a single collection whose `_id` is `::`; each document carries a `version` field for optimistic concurrency. The database and collection are created automatically on first write. Tune it with `MongoSessionStoreOptions`: + +```java +MongoSessionStore> store = + new MongoSessionStore<>( + client, + MongoSessionStoreOptions.builder() + .databaseName("genkit") // database name (default "genkit") + .collectionName("genkit_sessions") // collection name (default "genkit_sessions") + .pollIntervalMs(500) // how often change subscribers are polled (default 2000) + .build()); +``` + +The default shard size is 1 MiB, kept under MongoDB's 16 MB document-size limit. `MongoSessionStore` supports `onSnapshotStateChange` (via polling), so `chat.abort()` works. + ## Writing your own store Implement `SessionStore` to use any backend — a database, a cache, a cloud object store: diff --git a/docs/src/content/docs/getting-started.mdx b/docs/src/content/docs/getting-started.mdx index 77f91b992..a8b8de133 100644 --- a/docs/src/content/docs/getting-started.mdx +++ b/docs/src/content/docs/getting-started.mdx @@ -186,7 +186,7 @@ Genkit genkit = Genkit.builder() ModelResponse response = genkit.generate( GenerateOptions.builder() - .model("googleai/gemini-2.0-flash") + .model("googleai/gemini-2.5-flash") .prompt("Tell me a fun fact!") .build()); ``` diff --git a/docs/src/content/docs/index.mdx b/docs/src/content/docs/index.mdx index f8ad63954..76d4f5336 100644 --- a/docs/src/content/docs/index.mdx +++ b/docs/src/content/docs/index.mdx @@ -214,6 +214,36 @@ The fastest way to build AI features into your Java apps. +## Build stateful agents + +Define server-managed agents with memory, tools, and multi-agent delegation. Persist conversations in the store of your choice. + +```java +import com.google.genkit.agent.AgentConfig; +import com.google.genkit.ai.agent.*; + +Agent> assistant = genkit.beta().defineAgent( + AgentConfig.>builder() + .name("assistant") + .system("You are a helpful assistant. Remember what the user tells you.") + .model("googleai/gemini-3-flash") + .store(sessionStore) // persist sessions in Firestore, DynamoDB, Cosmos DB, PostgreSQL, MongoDB, ... + .build()); + +AgentChat> chat = assistant.chat(); +chat.send("My name is Ada Lovelace."); +String reply = chat.send("What is my name?").text(); // "Your name is Ada Lovelace." +``` + + + + + + + + + + ## Supported Providers @@ -238,6 +268,9 @@ The fastest way to build AI features into your Java apps. + + + diff --git a/docs/src/content/docs/models.mdx b/docs/src/content/docs/models.mdx index 0c9fd1c9f..d46d760d3 100644 --- a/docs/src/content/docs/models.mdx +++ b/docs/src/content/docs/models.mdx @@ -59,7 +59,7 @@ genkit.generate(GenerateOptions.builder() // Use Gemini genkit.generate(GenerateOptions.builder() - .model("googleai/gemini-2.0-flash") + .model("googleai/gemini-2.5-flash") .prompt("Hello!").build()); // Use Claude diff --git a/docs/src/content/docs/plugins/chroma.md b/docs/src/content/docs/plugins/chroma.md new file mode 100644 index 000000000..ae44a5422 --- /dev/null +++ b/docs/src/content/docs/plugins/chroma.md @@ -0,0 +1,66 @@ +--- +title: Chroma +description: Chroma vector database integration for Genkit RAG workflows. +--- + +The Chroma plugin registers retrievers and indexers backed by a [Chroma](https://www.trychroma.com/) server (v2 REST API) for Retrieval-Augmented Generation. + +## Installation + +```xml + + com.google.genkit + genkit-plugin-chroma + 1.0.0-SNAPSHOT + +``` + +## Requirements + +- A running Chroma server (`docker run -p 8000:8000 chromadb/chroma`) +- Java 21+ +- An embedder (e.g. from the Google GenAI plugin) + +## Usage + +`ChromaPlugin` registers a retriever and indexer named `chroma/` for each configured collection. + +```java +import com.google.genkit.plugins.chroma.ChromaCollectionConfig; +import com.google.genkit.plugins.chroma.ChromaPlugin; + +Genkit genkit = Genkit.builder() + .plugin(GoogleGenAIPlugin.create(apiKey)) + .plugin( + ChromaPlugin.builder() + .url("http://localhost:8000") // default + .addCollection( + ChromaCollectionConfig.builder() + .collectionName("films") + .embedderName("googleai/gemini-embedding-001") + .distance(ChromaCollectionConfig.Distance.COSINE) + .createCollectionIfNotExists(true) // default + .build()) + .build()) + .build(); + +// Index and retrieve +genkit.index("chroma/films", documents); +List results = genkit.retrieve("chroma/films", "a Christopher Nolan sci-fi film"); +``` + +## Configuration + +Tune per-collection settings with `ChromaCollectionConfig`: + +- `collectionName` — the Chroma collection name (required) +- `embedderName` — the embedder used to vectorize documents and queries (required) +- `distance` — `COSINE` (default), `L2`, or `INNER_PRODUCT` +- `createCollectionIfNotExists` — auto-create the collection (default `true`) +- `addAdditionalMetadata(key, value)` — metadata merged into every indexed document + +Plugin-level `tenant` and `database` default to `default_tenant` / `default_database`. + +## Sample + +See the [chroma sample](https://github.com/genkit-ai/genkit-java/tree/main/samples/chroma). diff --git a/docs/src/content/docs/plugins/firebase-functions.md b/docs/src/content/docs/plugins/firebase-functions.md index 3057ec16d..fb63e2cfc 100644 --- a/docs/src/content/docs/plugins/firebase-functions.md +++ b/docs/src/content/docs/plugins/firebase-functions.md @@ -49,7 +49,7 @@ public class GeneratePoemFunction implements HttpFunction { genkit.defineFlow("generatePoem", String.class, String.class, (ctx, topic) -> genkit.generate(GenerateOptions.builder() - .model("googleai/gemini-2.0-flash") + .model("googleai/gemini-2.5-flash") .prompt("Write a poem about: " + topic) .build()).getText()); diff --git a/docs/src/content/docs/plugins/firebase-vector-store.md b/docs/src/content/docs/plugins/firebase-vector-store.md index 9d15a1548..a65ab8325 100644 --- a/docs/src/content/docs/plugins/firebase-vector-store.md +++ b/docs/src/content/docs/plugins/firebase-vector-store.md @@ -28,7 +28,7 @@ Genkit genkit = Genkit.builder() .addRetriever(FirestoreRetrieverConfig.builder() .name("my-docs") .collection("documents") - .embedderName("googleai/text-embedding-004") + .embedderName("googleai/gemini-embedding-001") .vectorField("embedding") .contentField("content") .distanceMeasure(FirestoreRetrieverConfig.DistanceMeasure.COSINE) @@ -62,7 +62,7 @@ List results = genkit.retrieve("firebase/my-docs", "What is Genkit?"); genkit.defineFlow("ragQuery", String.class, String.class, (ctx, question) -> { List context = genkit.retrieve("firebase/my-docs", question); return genkit.generate(GenerateOptions.builder() - .model("googleai/gemini-2.0-flash") + .model("googleai/gemini-2.5-flash") .prompt(question) .docs(context) .build()).getText(); @@ -77,7 +77,7 @@ The plugin can automatically create the Firestore database and the required comp FirestoreRetrieverConfig.builder() .name("my-docs") .collection("documents") - .embedderName("googleai/text-embedding-004") + .embedderName("googleai/gemini-embedding-001") .vectorField("embedding") .contentField("content") .createDatabaseIfNotExists(true) @@ -100,7 +100,7 @@ gcloud firestore indexes composite create \ |--------|---------|-------------| | `name` | (required) | Retriever name — used as `firebase/{name}` | | `collection` | (required) | Firestore collection name | -| `embedderName` | (required) | Embedder to use (e.g., `googleai/text-embedding-004`) | +| `embedderName` | (required) | Embedder to use (e.g., `googleai/gemini-embedding-001`) | | `vectorField` | `"embedding"` | Firestore field storing the vector | | `contentField` | `"content"` | Firestore field storing the text content | | `distanceMeasure` | `COSINE` | `COSINE`, `EUCLIDEAN`, or `DOT_PRODUCT` | diff --git a/docs/src/content/docs/plugins/google-genai.md b/docs/src/content/docs/plugins/google-genai.md index 1f32e9a15..f3a5224f3 100644 --- a/docs/src/content/docs/plugins/google-genai.md +++ b/docs/src/content/docs/plugins/google-genai.md @@ -44,7 +44,7 @@ Genkit genkit = Genkit.builder() ModelResponse response = genkit.generate( GenerateOptions.builder() - .model("googleai/gemini-2.0-flash") + .model("googleai/gemini-2.5-flash") .prompt("Tell me about AI") .build()); ``` @@ -62,7 +62,7 @@ List documents = List.of( Document.fromText("Firebase provides cloud services") ); -EmbedResponse response = genkit.embed("googleai/text-embedding-004", documents); +EmbedResponse response = genkit.embed("googleai/gemini-embedding-001", documents); // Access embedding vectors float[] vector = response.getEmbeddings().get(0).getValues(); diff --git a/docs/src/content/docs/plugins/mongodb.md b/docs/src/content/docs/plugins/mongodb.md new file mode 100644 index 000000000..cb4cbfdde --- /dev/null +++ b/docs/src/content/docs/plugins/mongodb.md @@ -0,0 +1,83 @@ +--- +title: MongoDB +description: MongoDB integration for Genkit - Atlas Vector Search retrieval and agent session persistence. +--- + +The MongoDB plugin provides two capabilities: `MongoPlugin` registers Atlas Vector Search retrievers/indexers for RAG, and `MongoSessionStore` persists server-managed agent sessions. + +## Installation + +```xml + + com.google.genkit + genkit-plugin-mongodb + 1.0.0-SNAPSHOT + +``` + +## Requirements + +- MongoDB 4.4+ (Atlas Vector Search requires MongoDB Atlas or the `mongodb/mongodb-atlas-local` Docker image) +- Java 21+ + +## Vector search + +`MongoPlugin` registers a retriever and indexer named `mongodb/` for each configured collection, backed by an [Atlas Vector Search](https://www.mongodb.com/docs/atlas/atlas-vector-search/) index and the `$vectorSearch` aggregation stage. A plain MongoDB server does **not** support `$vectorSearch` — use a MongoDB Atlas cluster or the `mongodb/mongodb-atlas-local` Docker image. + +```java +import com.google.genkit.plugins.mongodb.MongoPlugin; +import com.google.genkit.plugins.mongodb.MongoVectorStoreConfig; + +Genkit genkit = Genkit.builder() + .plugin(GoogleGenAIPlugin.create(apiKey)) + .plugin( + MongoPlugin.builder() + .connectionString("mongodb://localhost:27017/?directConnection=true") + .addCollection( + MongoVectorStoreConfig.builder() + .collectionName("films") + .embedderName("googleai/gemini-embedding-001") + .dimension(768) + .similarity(MongoVectorStoreConfig.Similarity.COSINE) + .createIndexIfNotExists(true) // create the Atlas Vector Search index on first use + .build()) + .build()) + .build(); + +// Index and retrieve +genkit.index("mongodb/films", documents); +List results = genkit.retrieve("mongodb/films", "a Christopher Nolan sci-fi film"); +``` + +Tune per-collection settings with `MongoVectorStoreConfig` (database/collection names, embedder, index name, dimension, similarity — `COSINE`/`EUCLIDEAN`/`DOT_PRODUCT`, text/embedding field names, `numCandidates`, `createIndexIfNotExists`). See the [mongo-vector sample](https://github.com/genkit-ai/genkit-java/tree/main/samples/mongo-vector). + +## Session store + +Construct a `MongoSessionStore` from a `com.mongodb.client.MongoClient` and pass it to an agent's `.store(...)` to persist server-managed sessions in MongoDB: + +```java +import com.google.genkit.plugins.mongodb.session.MongoSessionStore; +import com.google.genkit.plugins.mongodb.session.MongoSessionStoreOptions; +import com.mongodb.client.MongoClient; +import com.mongodb.client.MongoClients; + +MongoClient client = MongoClients.create("mongodb://localhost:27017"); + +MongoSessionStore> store = + new MongoSessionStore<>(client); // uses database "genkit", collection "genkit_sessions" + +Agent> agent = genkit.beta().defineAgent( + AgentConfig.>builder() + .name("myAgent") + .system("You are a helpful assistant.") + .store(store) + .build()); +``` + +All records live in a single collection whose `_id` is `::`; the database and collection are created automatically on first write. It uses the same sharded checkpoint + RFC-6902 diff + pointer layout as the Firestore, DynamoDB, Cosmos DB, and PostgreSQL backends, and supports `onSnapshotStateChange` (via polling), so `chat.abort()` works. + +See [Session Stores](../../agents/session-stores#mongosessionstore) for options. + +## Sample + +See the [agents-mongo-session sample](https://github.com/genkit-ai/genkit-java/tree/main/samples/agents-mongo-session) and the [mongo-vector sample](https://github.com/genkit-ai/genkit-java/tree/main/samples/mongo-vector). diff --git a/docs/src/content/docs/plugins/opentelemetry.md b/docs/src/content/docs/plugins/opentelemetry.md index 45a01cfc3..6235a7ebc 100644 --- a/docs/src/content/docs/plugins/opentelemetry.md +++ b/docs/src/content/docs/plugins/opentelemetry.md @@ -124,7 +124,7 @@ public class App { .build(); // All generate/flow/tool calls now export traces and metrics - genkit.generate(options -> options.model("googleai/gemini-2.0-flash").prompt("Hi")); + genkit.generate(options -> options.model("googleai/gemini-2.5-flash").prompt("Hi")); } } ``` diff --git a/docs/src/content/docs/plugins/postgresql.md b/docs/src/content/docs/plugins/postgresql.md index 472d392ba..ed1a2bd9e 100644 --- a/docs/src/content/docs/plugins/postgresql.md +++ b/docs/src/content/docs/plugins/postgresql.md @@ -38,6 +38,21 @@ Genkit genkit = Genkit.builder() - Batch indexing - Metadata support +## Session store + +The plugin also ships `PostgresSessionStore`, a PostgreSQL-backed agent session store. Construct it from a `javax.sql.DataSource` and pass it to an agent's `.store(...)` to persist server-managed sessions in PostgreSQL: + +```java +import com.google.genkit.plugins.postgresql.session.PostgresSessionStore; +import com.google.genkit.plugins.postgresql.session.PostgresSessionStoreOptions; + +PostgresSessionStore> store = + new PostgresSessionStore<>( + dataSource, PostgresSessionStoreOptions.builder().createTableIfNotExists(true).build()); +``` + +See [Session Stores](../../agents/session-stores#postgressessionstore) for options and the agents-postgres-session sample. + ## Sample See the [postgresql sample](https://github.com/genkit-ai/genkit-java/tree/main/samples/postgresql). diff --git a/docs/src/content/docs/plugins/qdrant.md b/docs/src/content/docs/plugins/qdrant.md new file mode 100644 index 000000000..905976882 --- /dev/null +++ b/docs/src/content/docs/plugins/qdrant.md @@ -0,0 +1,70 @@ +--- +title: Qdrant +description: Qdrant vector database integration for Genkit RAG workflows. +--- + +The Qdrant plugin registers retrievers and indexers backed by a [Qdrant](https://qdrant.tech/) server (REST API) for Retrieval-Augmented Generation. + +## Installation + +```xml + + com.google.genkit + genkit-plugin-qdrant + 1.0.0-SNAPSHOT + +``` + +## Requirements + +- A running Qdrant server (`docker run -p 6333:6333 qdrant/qdrant`) +- Java 21+ +- An embedder (e.g. from the Google GenAI plugin) + +## Usage + +`QdrantPlugin` registers a retriever and indexer named `qdrant/` for each configured collection. + +```java +import com.google.genkit.plugins.qdrant.QdrantCollectionConfig; +import com.google.genkit.plugins.qdrant.QdrantPlugin; + +Genkit genkit = Genkit.builder() + .plugin(GoogleGenAIPlugin.create(apiKey)) + .plugin( + QdrantPlugin.builder() + .url("http://localhost:6333") // default + .apiKey(System.getenv("QDRANT_API_KEY")) // optional; required for Qdrant Cloud + .addCollection( + QdrantCollectionConfig.builder() + .collectionName("films") + .embedderName("googleai/gemini-embedding-001") + .dimension(768) + .distance(QdrantCollectionConfig.Distance.COSINE) + .createCollectionIfNotExists(true) // default + .build()) + .build()) + .build(); + +// Index and retrieve +genkit.index("qdrant/films", documents); +List results = genkit.retrieve("qdrant/films", "a Christopher Nolan sci-fi film"); +``` + +## Configuration + +Tune per-collection settings with `QdrantCollectionConfig`: + +- `collectionName` — the Qdrant collection name (required) +- `embedderName` — the embedder used to vectorize documents and queries (required) +- `dimension` — the embedding dimension used when creating the collection (default `768`). The store also probes the embedder on first use and creates the collection with the model's actual output dimension, so this is only a fallback. +- `distance` — `COSINE` (default), `EUCLIDEAN`, or `DOT_PRODUCT` +- `textPayloadKey` — the payload key that stores the document text (default `text`) +- `createCollectionIfNotExists` — auto-create the collection (default `true`) +- `addAdditionalMetadata(key, value)` — metadata merged into every indexed point's payload + +Document ids that are not integers or UUIDs are hashed into a stable UUID (Qdrant point ids must be an unsigned integer or a UUID). + +## Sample + +See the [qdrant sample](https://github.com/genkit-ai/genkit-java/tree/main/samples/qdrant). diff --git a/genkit/src/main/java/com/google/genkit/Genkit.java b/genkit/src/main/java/com/google/genkit/Genkit.java index 5d010a320..743d93fd4 100644 --- a/genkit/src/main/java/com/google/genkit/Genkit.java +++ b/genkit/src/main/java/com/google/genkit/Genkit.java @@ -2099,7 +2099,7 @@ public GenkitOptions getOptions() { * AgentConfig.builder() * .name("helper") * .system("You are helpful.") - * .model("googleai/gemini-2.0-flash") + * .model("googleai/gemini-2.5-flash") * .build()); * } * diff --git a/plugins/chroma/pom.xml b/plugins/chroma/pom.xml new file mode 100644 index 000000000..879aac02d --- /dev/null +++ b/plugins/chroma/pom.xml @@ -0,0 +1,78 @@ + + + + 4.0.0 + + + com.google.genkit + genkit-parent + 1.0.0-SNAPSHOT + ../../pom.xml + + + genkit-plugin-chroma + jar + Genkit Chroma Plugin + Chroma vector database integration for Genkit - indexer and retriever for RAG workflows + + + false + + + + + + com.google.genkit + genkit-core + ${project.version} + + + com.google.genkit + genkit-ai + ${project.version} + + + + + com.fasterxml.jackson.core + jackson-databind + + + + + org.slf4j + slf4j-api + + + + + org.junit.jupiter + junit-jupiter + test + + + org.mockito + mockito-core + test + + + diff --git a/plugins/chroma/src/main/java/com/google/genkit/plugins/chroma/ChromaCollectionConfig.java b/plugins/chroma/src/main/java/com/google/genkit/plugins/chroma/ChromaCollectionConfig.java new file mode 100644 index 000000000..1e04d499a --- /dev/null +++ b/plugins/chroma/src/main/java/com/google/genkit/plugins/chroma/ChromaCollectionConfig.java @@ -0,0 +1,206 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed 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. + * + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.google.genkit.plugins.chroma; + +import java.util.HashMap; +import java.util.Map; + +/** + * Configuration for a single Chroma collection managed by {@link ChromaPlugin}. + * + *

Each config registers a retriever and indexer named {@code chroma/}. + */ +public final class ChromaCollectionConfig { + + /** Distance function used by the Chroma HNSW index. */ + public enum Distance { + COSINE("cosine"), + L2("l2"), + INNER_PRODUCT("ip"); + + private final String value; + + Distance(String value) { + this.value = value; + } + + /** + * Returns the Chroma {@code hnsw:space} value. + * + * @return the space name + */ + public String getValue() { + return value; + } + } + + private final String collectionName; + private final String embedderName; + private final Distance distance; + private final boolean createCollectionIfNotExists; + private final Map additionalMetadata; + + private ChromaCollectionConfig(Builder builder) { + this.collectionName = builder.collectionName; + this.embedderName = builder.embedderName; + this.distance = builder.distance; + this.createCollectionIfNotExists = builder.createCollectionIfNotExists; + this.additionalMetadata = new HashMap<>(builder.additionalMetadata); + } + + /** + * Returns the Chroma collection name. + * + * @return the collection name + */ + public String getCollectionName() { + return collectionName; + } + + /** + * Returns the name of the embedder used to vectorize documents and queries. + * + * @return the embedder name + */ + public String getEmbedderName() { + return embedderName; + } + + /** + * Returns the distance function (default {@link Distance#COSINE}). + * + * @return the distance function + */ + public Distance getDistance() { + return distance; + } + + /** + * Returns whether to create the collection on first use if it does not exist (default {@code + * true}). + * + * @return {@code true} if the collection should be created when missing + */ + public boolean isCreateCollectionIfNotExists() { + return createCollectionIfNotExists; + } + + /** + * Returns additional metadata merged into every indexed document. + * + * @return the additional metadata + */ + public Map getAdditionalMetadata() { + return additionalMetadata; + } + + /** + * Creates a new builder. + * + * @return a new builder + */ + public static Builder builder() { + return new Builder(); + } + + /** Builder for {@link ChromaCollectionConfig}. */ + public static final class Builder { + private String collectionName; + private String embedderName; + private Distance distance = Distance.COSINE; + private boolean createCollectionIfNotExists = true; + private final Map additionalMetadata = new HashMap<>(); + + private Builder() {} + + /** + * Sets the collection name. + * + * @param collectionName the collection name + * @return this builder + */ + public Builder collectionName(String collectionName) { + this.collectionName = collectionName; + return this; + } + + /** + * Sets the embedder name. + * + * @param embedderName the embedder name + * @return this builder + */ + public Builder embedderName(String embedderName) { + this.embedderName = embedderName; + return this; + } + + /** + * Sets the distance function. + * + * @param distance the distance function + * @return this builder + */ + public Builder distance(Distance distance) { + this.distance = distance; + return this; + } + + /** + * Sets whether to create the collection on first use if it does not exist. + * + * @param createCollectionIfNotExists whether to create the collection when missing + * @return this builder + */ + public Builder createCollectionIfNotExists(boolean createCollectionIfNotExists) { + this.createCollectionIfNotExists = createCollectionIfNotExists; + return this; + } + + /** + * Adds a metadata entry merged into every indexed document. + * + * @param key the metadata key + * @param value the metadata value + * @return this builder + */ + public Builder addAdditionalMetadata(String key, Object value) { + this.additionalMetadata.put(key, value); + return this; + } + + /** + * Builds a new {@code ChromaCollectionConfig}. + * + * @return a new config instance + */ + public ChromaCollectionConfig build() { + if (collectionName == null || collectionName.isBlank()) { + throw new IllegalArgumentException("collectionName must be non-empty"); + } + if (embedderName == null || embedderName.isBlank()) { + throw new IllegalArgumentException("embedderName must be non-empty"); + } + if (distance == null) { + throw new IllegalArgumentException("distance must be non-null"); + } + return new ChromaCollectionConfig(this); + } + } +} diff --git a/plugins/chroma/src/main/java/com/google/genkit/plugins/chroma/ChromaPlugin.java b/plugins/chroma/src/main/java/com/google/genkit/plugins/chroma/ChromaPlugin.java new file mode 100644 index 000000000..06cd45fd1 --- /dev/null +++ b/plugins/chroma/src/main/java/com/google/genkit/plugins/chroma/ChromaPlugin.java @@ -0,0 +1,201 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed 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. + * + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.google.genkit.plugins.chroma; + +import com.google.genkit.ai.Embedder; +import com.google.genkit.core.Action; +import com.google.genkit.core.ActionType; +import com.google.genkit.core.Plugin; +import com.google.genkit.core.Registry; +import java.util.ArrayList; +import java.util.List; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Chroma vector database plugin for Genkit. + * + *

Registers a retriever and indexer named {@code chroma/} for each configured + * collection, talking to a Chroma server over its v2 REST API. + * + *

Example usage: + * + *

{@code
+ * Genkit genkit = Genkit.builder()
+ *     .plugin(GoogleGenAIPlugin.create(apiKey))
+ *     .plugin(
+ *         ChromaPlugin.builder()
+ *             .url("http://localhost:8000")
+ *             .addCollection(
+ *                 ChromaCollectionConfig.builder()
+ *                     .collectionName("films")
+ *                     .embedderName("googleai/gemini-embedding-001")
+ *                     .build())
+ *             .build())
+ *     .build();
+ * }
+ */ +public final class ChromaPlugin implements Plugin { + + /** The plugin name; used as the {@code chroma/...} action prefix. */ + public static final String PLUGIN_NAME = "chroma"; + + /** Default Chroma tenant. */ + public static final String DEFAULT_TENANT = "default_tenant"; + + /** Default Chroma database. */ + public static final String DEFAULT_DATABASE = "default_database"; + + /** Default Chroma server URL. */ + public static final String DEFAULT_URL = "http://localhost:8000"; + + private static final Logger logger = LoggerFactory.getLogger(ChromaPlugin.class); + + private final String url; + private final String tenant; + private final String database; + private final List collectionConfigs; + + private ChromaPlugin(Builder builder) { + this.url = builder.url; + this.tenant = builder.tenant; + this.database = builder.database; + this.collectionConfigs = new ArrayList<>(builder.collectionConfigs); + } + + /** + * Creates a new builder. + * + * @return a new builder + */ + public static Builder builder() { + return new Builder(); + } + + @Override + public String getName() { + return PLUGIN_NAME; + } + + @Override + public List> init() { + throw new IllegalStateException( + "ChromaPlugin requires a Registry to resolve embedders. Use init(registry) instead."); + } + + @Override + public List> init(Registry registry) { + List> actions = new ArrayList<>(); + for (ChromaCollectionConfig config : collectionConfigs) { + String embedderKey = ActionType.EMBEDDER.keyFromName(config.getEmbedderName()); + Action embedderAction = registry.lookupAction(embedderKey); + if (embedderAction == null) { + throw new IllegalStateException( + "Embedder not found: " + + config.getEmbedderName() + + ". Make sure the embedder plugin is registered before ChromaPlugin."); + } + if (!(embedderAction instanceof Embedder embedder)) { + throw new IllegalStateException( + "Action " + config.getEmbedderName() + " is not an Embedder"); + } + + ChromaVectorStore store = new ChromaVectorStore(url, tenant, database, config, embedder); + actions.add(store.createRetriever()); + actions.add(store.createIndexer()); + logger.info("Registered Chroma vector store: {}/{}", PLUGIN_NAME, config.getCollectionName()); + } + return actions; + } + + /** Builder for {@link ChromaPlugin}. */ + public static final class Builder { + private String url = DEFAULT_URL; + private String tenant = DEFAULT_TENANT; + private String database = DEFAULT_DATABASE; + private final List collectionConfigs = new ArrayList<>(); + + private Builder() {} + + /** + * Sets the Chroma server URL (default {@value #DEFAULT_URL}). + * + * @param url the server URL + * @return this builder + */ + public Builder url(String url) { + this.url = url; + return this; + } + + /** + * Sets the Chroma tenant (default {@value #DEFAULT_TENANT}). + * + * @param tenant the tenant + * @return this builder + */ + public Builder tenant(String tenant) { + this.tenant = tenant; + return this; + } + + /** + * Sets the Chroma database (default {@value #DEFAULT_DATABASE}). + * + * @param database the database + * @return this builder + */ + public Builder database(String database) { + this.database = database; + return this; + } + + /** + * Adds a collection configuration. + * + * @param config the collection configuration + * @return this builder + */ + public Builder addCollection(ChromaCollectionConfig config) { + this.collectionConfigs.add(config); + return this; + } + + /** + * Builds the plugin. + * + * @return a new {@code ChromaPlugin} + */ + public ChromaPlugin build() { + if (url == null || url.isBlank()) { + throw new IllegalStateException("url is required"); + } + if (tenant == null || tenant.isBlank()) { + throw new IllegalStateException("tenant is required"); + } + if (database == null || database.isBlank()) { + throw new IllegalStateException("database is required"); + } + if (collectionConfigs.isEmpty()) { + throw new IllegalStateException("At least one collection configuration is required"); + } + return new ChromaPlugin(this); + } + } +} diff --git a/plugins/chroma/src/main/java/com/google/genkit/plugins/chroma/ChromaVectorStore.java b/plugins/chroma/src/main/java/com/google/genkit/plugins/chroma/ChromaVectorStore.java new file mode 100644 index 000000000..ea1700cdd --- /dev/null +++ b/plugins/chroma/src/main/java/com/google/genkit/plugins/chroma/ChromaVectorStore.java @@ -0,0 +1,311 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed 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. + * + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.google.genkit.plugins.chroma; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.google.genkit.ai.Document; +import com.google.genkit.ai.EmbedRequest; +import com.google.genkit.ai.EmbedResponse; +import com.google.genkit.ai.Embedder; +import com.google.genkit.ai.Indexer; +import com.google.genkit.ai.IndexerRequest; +import com.google.genkit.ai.IndexerResponse; +import com.google.genkit.ai.Retriever; +import com.google.genkit.ai.RetrieverRequest; +import com.google.genkit.ai.RetrieverResponse; +import com.google.genkit.core.ActionContext; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Chroma vector store backed by the Chroma v2 REST API. + * + *

Indexes documents (id + text + embedding + metadata) into a Chroma collection and retrieves + * the nearest neighbors of a query embedding. + */ +public final class ChromaVectorStore { + + private static final Logger logger = LoggerFactory.getLogger(ChromaVectorStore.class); + private static final ObjectMapper MAPPER = new ObjectMapper(); + + private final HttpClient http = HttpClient.newHttpClient(); + private final String baseUrl; + private final String tenant; + private final String database; + private final ChromaCollectionConfig config; + private final Embedder embedder; + + private volatile String collectionId; + + /** + * Creates a new store. + * + * @param baseUrl the Chroma server base URL (e.g. {@code http://localhost:8000}) + * @param tenant the Chroma tenant + * @param database the Chroma database + * @param config the collection configuration + * @param embedder the embedder used to vectorize documents and queries + */ + public ChromaVectorStore( + String baseUrl, + String tenant, + String database, + ChromaCollectionConfig config, + Embedder embedder) { + this.baseUrl = baseUrl.endsWith("/") ? baseUrl.substring(0, baseUrl.length() - 1) : baseUrl; + this.tenant = tenant; + this.database = database; + this.config = config; + this.embedder = embedder; + } + + /** Creates the retriever action registered by the plugin. */ + Retriever createRetriever() { + String name = ChromaPlugin.PLUGIN_NAME + "/" + config.getCollectionName(); + return Retriever.builder().name(name).handler(this::retrieve).build(); + } + + /** Creates the indexer action registered by the plugin. */ + Indexer createIndexer() { + String name = ChromaPlugin.PLUGIN_NAME + "/" + config.getCollectionName(); + return Indexer.builder().name(name).handler(this::index).build(); + } + + private String collectionsPath() { + return "/api/v2/tenants/" + tenant + "/databases/" + database + "/collections"; + } + + private synchronized String ensureCollection() { + if (collectionId != null) { + return collectionId; + } + ObjectNode body = MAPPER.createObjectNode(); + body.put("name", config.getCollectionName()); + body.put("get_or_create", config.isCreateCollectionIfNotExists()); + ObjectNode metadata = body.putObject("metadata"); + metadata.put("hnsw:space", config.getDistance().getValue()); + + JsonNode resp = send("POST", collectionsPath(), body); + JsonNode id = resp.get("id"); + if (id == null || id.isNull()) { + throw new RuntimeException( + "Chroma did not return a collection id for " + config.getCollectionName()); + } + collectionId = id.asText(); + logger.info("Using Chroma collection {} ({})", config.getCollectionName(), collectionId); + return collectionId; + } + + /** + * Retrieves documents similar to the query. + * + * @param context the action context + * @param request the retriever request + * @return the retriever response with matching documents + */ + public RetrieverResponse retrieve(ActionContext context, RetrieverRequest request) { + Document queryDoc = request.getQuery(); + if (queryDoc == null || queryDoc.text() == null || queryDoc.text().isBlank()) { + throw new RuntimeException("Query document has no text content"); + } + int topK = + request.getOptions() != null && request.getOptions().getK() != null + ? request.getOptions().getK() + : 10; + List queryEmbedding = generateEmbedding(context, queryDoc.text()); + String id = ensureCollection(); + + ObjectNode body = MAPPER.createObjectNode(); + ArrayNode queryEmbeddings = body.putArray("query_embeddings"); + queryEmbeddings.add(floatsToArray(queryEmbedding)); + body.put("n_results", topK); + ArrayNode include = body.putArray("include"); + include.add("documents"); + include.add("metadatas"); + include.add("distances"); + + JsonNode resp = send("POST", collectionsPath() + "/" + id + "/query", body); + List documents = new ArrayList<>(); + JsonNode idsOuter = resp.get("ids"); + if (idsOuter == null || !idsOuter.isArray() || idsOuter.isEmpty()) { + return new RetrieverResponse(documents); + } + JsonNode ids = idsOuter.get(0); + JsonNode docs = firstRow(resp.get("documents")); + JsonNode metadatas = firstRow(resp.get("metadatas")); + JsonNode distances = firstRow(resp.get("distances")); + for (int i = 0; i < ids.size(); i++) { + String content = + docs != null && docs.get(i) != null && !docs.get(i).isNull() ? docs.get(i).asText() : ""; + Map metadata = new HashMap<>(); + if (metadatas != null && metadatas.get(i) != null && metadatas.get(i).isObject()) { + metadata.putAll(MAPPER.convertValue(metadatas.get(i), Map.class)); + } + metadata.put("id", ids.get(i).asText()); + if (distances != null && distances.get(i) != null && distances.get(i).isNumber()) { + double distance = distances.get(i).asDouble(); + metadata.put("distance", distance); + metadata.put("score", 1.0 - distance); + } + Document doc = new Document(content); + doc.setMetadata(metadata); + documents.add(doc); + } + logger.debug( + "Retrieved {} documents from collection {}", documents.size(), config.getCollectionName()); + return new RetrieverResponse(documents); + } + + /** + * Indexes documents into the collection, generating an embedding for each. + * + * @param context the action context + * @param request the indexer request + * @return the indexer response + */ + public IndexerResponse index(ActionContext context, IndexerRequest request) { + List documents = request.getDocuments(); + if (documents == null || documents.isEmpty()) { + logger.warn("No documents to index"); + return new IndexerResponse(); + } + String id = ensureCollection(); + + ObjectNode body = MAPPER.createObjectNode(); + ArrayNode ids = body.putArray("ids"); + ArrayNode embeddings = body.putArray("embeddings"); + ArrayNode contents = body.putArray("documents"); + ArrayNode metadatas = body.putArray("metadatas"); + + for (Document doc : documents) { + String content = doc.text() != null ? doc.text() : ""; + List embedding = generateEmbedding(context, content); + ids.add(getOrGenerateId(doc)); + embeddings.add(floatsToArray(embedding)); + contents.add(content); + + Map metadata = new HashMap<>(); + if (doc.getMetadata() != null) { + for (Map.Entry entry : doc.getMetadata().entrySet()) { + if (!"id".equals(entry.getKey())) { + metadata.put(entry.getKey(), entry.getValue()); + } + } + } + metadata.putAll(config.getAdditionalMetadata()); + // Chroma rejects empty metadata objects; send null when there is nothing to store. + if (metadata.isEmpty()) { + metadatas.addNull(); + } else { + metadatas.add(MAPPER.valueToTree(metadata)); + } + } + + send("POST", collectionsPath() + "/" + id + "/add", body); + logger.info( + "Indexed {} documents into collection {}", documents.size(), config.getCollectionName()); + return new IndexerResponse(); + } + + private static JsonNode firstRow(JsonNode outer) { + return (outer != null && outer.isArray() && !outer.isEmpty()) ? outer.get(0) : null; + } + + private ArrayNode floatsToArray(List values) { + ArrayNode arr = MAPPER.createArrayNode(); + for (float v : values) { + arr.add(v); + } + return arr; + } + + private List generateEmbedding(ActionContext ctx, String text) { + EmbedResponse response = embedder.run(ctx, new EmbedRequest(List.of(new Document(text)))); + if (response.getEmbeddings() == null || response.getEmbeddings().isEmpty()) { + throw new RuntimeException("Failed to generate embedding for text"); + } + float[] values = response.getEmbeddings().get(0).getValues(); + List out = new ArrayList<>(values.length); + for (float v : values) { + out.add(v); + } + return out; + } + + private String getOrGenerateId(Document doc) { + if (doc.getMetadata() != null && doc.getMetadata().get("id") != null) { + return doc.getMetadata().get("id").toString(); + } + return UUID.randomUUID().toString(); + } + + private JsonNode send(String method, String path, JsonNode body) { + try { + HttpRequest.Builder builder = + HttpRequest.newBuilder() + .uri(URI.create(baseUrl + path)) + .header("Content-Type", "application/json") + .header("Accept", "application/json"); + if (body != null) { + builder.method( + method, + HttpRequest.BodyPublishers.ofString( + MAPPER.writeValueAsString(body), StandardCharsets.UTF_8)); + } else { + builder.method(method, HttpRequest.BodyPublishers.noBody()); + } + HttpResponse response = + http.send(builder.build(), HttpResponse.BodyHandlers.ofString()); + if (response.statusCode() / 100 != 2) { + throw new RuntimeException( + "Chroma request " + + method + + " " + + path + + " failed (" + + response.statusCode() + + "): " + + response.body()); + } + String responseBody = response.body(); + if (responseBody == null || responseBody.isBlank()) { + return MAPPER.createObjectNode(); + } + return MAPPER.readTree(responseBody); + } catch (RuntimeException e) { + throw e; + } catch (Exception e) { + throw new RuntimeException( + "Chroma request " + method + " " + path + " failed: " + e.getMessage(), e); + } + } +} diff --git a/plugins/chroma/src/main/java/com/google/genkit/plugins/chroma/package-info.java b/plugins/chroma/src/main/java/com/google/genkit/plugins/chroma/package-info.java new file mode 100644 index 000000000..2cb3b0058 --- /dev/null +++ b/plugins/chroma/src/main/java/com/google/genkit/plugins/chroma/package-info.java @@ -0,0 +1,25 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed 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. + * + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Chroma vector database integration for Genkit. + * + *

{@link com.google.genkit.plugins.chroma.ChromaPlugin} registers retrievers and indexers backed + * by a Chroma server (v2 REST API) for RAG workflows. + */ +package com.google.genkit.plugins.chroma; diff --git a/plugins/chroma/src/test/java/com/google/genkit/plugins/chroma/ChromaCollectionConfigTest.java b/plugins/chroma/src/test/java/com/google/genkit/plugins/chroma/ChromaCollectionConfigTest.java new file mode 100644 index 000000000..07a425916 --- /dev/null +++ b/plugins/chroma/src/test/java/com/google/genkit/plugins/chroma/ChromaCollectionConfigTest.java @@ -0,0 +1,70 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed 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. + * + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.google.genkit.plugins.chroma; + +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 org.junit.jupiter.api.Test; + +/** Unit tests for {@link ChromaCollectionConfig}. */ +class ChromaCollectionConfigTest { + + @Test + void defaultsAreSane() { + ChromaCollectionConfig c = + ChromaCollectionConfig.builder() + .collectionName("films") + .embedderName("googleai/gemini-embedding-001") + .build(); + assertEquals("films", c.getCollectionName()); + assertEquals("googleai/gemini-embedding-001", c.getEmbedderName()); + assertEquals(ChromaCollectionConfig.Distance.COSINE, c.getDistance()); + assertEquals("cosine", c.getDistance().getValue()); + assertTrue(c.isCreateCollectionIfNotExists()); + assertTrue(c.getAdditionalMetadata().isEmpty()); + } + + @Test + void customBuilder() { + ChromaCollectionConfig c = + ChromaCollectionConfig.builder() + .collectionName("docs") + .embedderName("e") + .distance(ChromaCollectionConfig.Distance.L2) + .createCollectionIfNotExists(false) + .addAdditionalMetadata("source", "wiki") + .build(); + assertEquals(ChromaCollectionConfig.Distance.L2, c.getDistance()); + assertEquals("l2", c.getDistance().getValue()); + assertEquals(false, c.isCreateCollectionIfNotExists()); + assertEquals("wiki", c.getAdditionalMetadata().get("source")); + } + + @Test + void builderValidates() { + assertThrows( + IllegalArgumentException.class, + () -> ChromaCollectionConfig.builder().embedderName("e").build()); + assertThrows( + IllegalArgumentException.class, + () -> ChromaCollectionConfig.builder().collectionName("c").build()); + } +} diff --git a/plugins/chroma/src/test/java/com/google/genkit/plugins/chroma/ChromaPluginTest.java b/plugins/chroma/src/test/java/com/google/genkit/plugins/chroma/ChromaPluginTest.java new file mode 100644 index 000000000..5eff068e3 --- /dev/null +++ b/plugins/chroma/src/test/java/com/google/genkit/plugins/chroma/ChromaPluginTest.java @@ -0,0 +1,55 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed 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. + * + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.google.genkit.plugins.chroma; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import org.junit.jupiter.api.Test; + +/** Unit tests for {@link ChromaPlugin}. */ +class ChromaPluginTest { + + private static ChromaCollectionConfig config() { + return ChromaCollectionConfig.builder().collectionName("films").embedderName("e").build(); + } + + @Test + void getName() { + assertEquals("chroma", ChromaPlugin.builder().addCollection(config()).build().getName()); + } + + @Test + void builderDefaults() { + // A valid plugin builds with default url/tenant/database. + ChromaPlugin plugin = ChromaPlugin.builder().addCollection(config()).build(); + assertEquals("chroma", plugin.getName()); + } + + @Test + void requiresAtLeastOneCollection() { + assertThrows(IllegalStateException.class, () -> ChromaPlugin.builder().build()); + } + + @Test + void initWithoutRegistryThrows() { + ChromaPlugin plugin = ChromaPlugin.builder().addCollection(config()).build(); + assertThrows(IllegalStateException.class, plugin::init); + } +} diff --git a/plugins/chroma/src/test/java/com/google/genkit/plugins/chroma/ChromaVectorStoreTest.java b/plugins/chroma/src/test/java/com/google/genkit/plugins/chroma/ChromaVectorStoreTest.java new file mode 100644 index 000000000..5cbd5544b --- /dev/null +++ b/plugins/chroma/src/test/java/com/google/genkit/plugins/chroma/ChromaVectorStoreTest.java @@ -0,0 +1,111 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed 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. + * + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.google.genkit.plugins.chroma; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assumptions.assumeTrue; + +import com.google.genkit.ai.Document; +import com.google.genkit.ai.EmbedResponse; +import com.google.genkit.ai.Embedder; +import com.google.genkit.ai.EmbedderInfo; +import com.google.genkit.ai.IndexerRequest; +import com.google.genkit.ai.RetrieverRequest; +import com.google.genkit.ai.RetrieverResponse; +import java.util.List; +import java.util.Random; +import java.util.UUID; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +/** + * Integration tests for {@link ChromaVectorStore}, gated on the {@code CHROMA_URL} environment + * variable (e.g. a Chroma server started with Docker). Skipped via {@link + * org.junit.jupiter.api.Assumptions} when unset. Uses a deterministic stub embedder so a query + * equal to an indexed document's text retrieves that document first. + */ +class ChromaVectorStoreTest { + + private static final String URL = System.getenv("CHROMA_URL"); + private static final int DIM = 16; + + private ChromaVectorStore store; + + private static boolean configured() { + return URL != null && !URL.isEmpty(); + } + + /** Deterministic embedder: identical text always maps to the same vector. */ + private static Embedder stubEmbedder() { + return new Embedder( + "test/stub", + new EmbedderInfo(), + (ctx, req) -> { + List out = new java.util.ArrayList<>(); + for (Document doc : req.getDocuments()) { + Random random = new Random(doc.text().hashCode()); + float[] values = new float[DIM]; + for (int i = 0; i < DIM; i++) { + values[i] = random.nextFloat(); + } + out.add(new EmbedResponse.Embedding(values)); + } + return new EmbedResponse(out); + }); + } + + @BeforeEach + void setUp() { + if (!configured()) { + return; + } + ChromaCollectionConfig config = + ChromaCollectionConfig.builder() + .collectionName("genkit_test_" + UUID.randomUUID().toString().replace("-", "")) + .embedderName("test/stub") + .build(); + store = + new ChromaVectorStore(URL, "default_tenant", "default_database", config, stubEmbedder()); + } + + @Test + void indexThenRetrieveReturnsNearestFirst() { + assumeTrue(configured()); + List docs = + List.of( + Document.fromText("The Matrix is a sci-fi film about simulated reality."), + Document.fromText("The Godfather is a crime film about a mafia family."), + Document.fromText("Inception is a sci-fi film about dreams.")); + store.index(null, new IndexerRequest(docs)); + + RetrieverRequest request = + new RetrieverRequest( + docs.get(1).text() == null ? null : Document.fromText(docs.get(1).text())); + RetrieverRequest.RetrieverOptions options = new RetrieverRequest.RetrieverOptions(); + options.setK(3); + request.setOptions(options); + + RetrieverResponse response = store.retrieve(null, request); + assertFalse(response.getDocuments().isEmpty()); + assertEquals( + "The Godfather is a crime film about a mafia family.", + response.getDocuments().get(0).text()); + } +} diff --git a/plugins/evaluators/README.md b/plugins/evaluators/README.md index a353211b2..4c7ece9ce 100644 --- a/plugins/evaluators/README.md +++ b/plugins/evaluators/README.md @@ -51,7 +51,7 @@ import com.google.genkit.plugins.evaluators.GenkitMetric; Genkit genkit = Genkit.builder() .addPlugin(EvaluatorsPlugin.create( EvaluatorsPluginOptions.builder() - .judge("googleai/gemini-2.0-flash") + .judge("googleai/gemini-2.5-flash") .metricTypes(List.of( GenkitMetric.FAITHFULNESS, GenkitMetric.ANSWER_RELEVANCY, @@ -67,7 +67,7 @@ Genkit genkit = Genkit.builder() Genkit genkit = Genkit.builder() .addPlugin(EvaluatorsPlugin.create( EvaluatorsPluginOptions.builder() - .judge("googleai/gemini-2.0-flash") + .judge("googleai/gemini-2.5-flash") .useAllMetrics() .build())) .build(); @@ -79,8 +79,8 @@ Genkit genkit = Genkit.builder() Genkit genkit = Genkit.builder() .addPlugin(EvaluatorsPlugin.create( EvaluatorsPluginOptions.builder() - .judge("googleai/gemini-2.0-flash") // Default judge - .embedder("googleai/text-embedding-004") // Default embedder + .judge("googleai/gemini-2.5-flash") // Default judge + .embedder("googleai/gemini-embedding-001") // Default embedder .metrics(List.of( // Use defaults MetricConfig.of(GenkitMetric.FAITHFULNESS), @@ -201,7 +201,7 @@ for (EvalResponse response : results) { Any model registered with Genkit can be used as a judge: -- `googleai/gemini-2.0-flash` +- `googleai/gemini-2.5-flash` - `googleai/gemini-1.5-pro` - `openai/gpt-4o` - `anthropic/claude-3-sonnet` diff --git a/plugins/evaluators/src/main/java/com/google/genkit/plugins/evaluators/EvaluatorsPlugin.java b/plugins/evaluators/src/main/java/com/google/genkit/plugins/evaluators/EvaluatorsPlugin.java index 29f1562f3..ed0ef1a5d 100644 --- a/plugins/evaluators/src/main/java/com/google/genkit/plugins/evaluators/EvaluatorsPlugin.java +++ b/plugins/evaluators/src/main/java/com/google/genkit/plugins/evaluators/EvaluatorsPlugin.java @@ -58,7 +58,7 @@ * .addPlugin( * EvaluatorsPlugin.create( * EvaluatorsPluginOptions.builder() - * .judge("googleai/gemini-2.0-flash") + * .judge("googleai/gemini-2.5-flash") * .metricTypes(List.of(GenkitMetric.FAITHFULNESS, GenkitMetric.ANSWER_RELEVANCY)) * .build())) * .build(); diff --git a/plugins/evaluators/src/test/java/com/google/genkit/plugins/evaluators/EvaluatorsPluginOptionsTest.java b/plugins/evaluators/src/test/java/com/google/genkit/plugins/evaluators/EvaluatorsPluginOptionsTest.java index d35ee90fe..fdccf7ded 100644 --- a/plugins/evaluators/src/test/java/com/google/genkit/plugins/evaluators/EvaluatorsPluginOptionsTest.java +++ b/plugins/evaluators/src/test/java/com/google/genkit/plugins/evaluators/EvaluatorsPluginOptionsTest.java @@ -39,9 +39,9 @@ void testBuilderWithDefaults() { @Test void testBuilderWithJudge() { EvaluatorsPluginOptions options = - EvaluatorsPluginOptions.builder().judge("googleai/gemini-2.0-flash").build(); + EvaluatorsPluginOptions.builder().judge("googleai/gemini-2.5-flash").build(); - assertEquals("googleai/gemini-2.0-flash", options.getJudge()); + assertEquals("googleai/gemini-2.5-flash", options.getJudge()); } @Test diff --git a/plugins/firebase/README.md b/plugins/firebase/README.md index 5bf0fef71..44bd3de9e 100644 --- a/plugins/firebase/README.md +++ b/plugins/firebase/README.md @@ -44,7 +44,7 @@ Genkit genkit = Genkit.builder() .addRetriever(FirestoreRetrieverConfig.builder() .name("myDocs") .collection("documents") - .embedderName("googleai/text-embedding-004") + .embedderName("googleai/gemini-embedding-001") .vectorField("embedding") .contentField("content") .build()) @@ -91,7 +91,7 @@ Genkit genkit = Genkit.builder() .addRetriever(FirestoreRetrieverConfig.builder() .name("myDocs") .collection("documents") - .embedderName("googleai/text-embedding-004") + .embedderName("googleai/gemini-embedding-001") .vectorField("embedding") .contentField("content") .distanceMeasure(FirestoreRetrieverConfig.DistanceMeasure.COSINE) @@ -206,7 +206,7 @@ public class RAGFunction implements HttpFunction { .addRetriever(FirestoreRetrieverConfig.builder() .name("docs") .collection("documents") - .embedderName("googleai/text-embedding-004") + .embedderName("googleai/gemini-embedding-001") .vectorField("embedding") .contentField("content") .build()) @@ -334,7 +334,7 @@ public class FirebaseRAGExample { .addRetriever(FirestoreRetrieverConfig.builder() .name("knowledge-base") .collection("documents") - .embedderName("googleai/text-embedding-004") + .embedderName("googleai/gemini-embedding-001") .vectorField("embedding") .contentField("content") .build()) diff --git a/plugins/firebase/src/main/java/com/google/genkit/plugins/firebase/FirebasePlugin.java b/plugins/firebase/src/main/java/com/google/genkit/plugins/firebase/FirebasePlugin.java index 1fdb17f58..eac41ba83 100644 --- a/plugins/firebase/src/main/java/com/google/genkit/plugins/firebase/FirebasePlugin.java +++ b/plugins/firebase/src/main/java/com/google/genkit/plugins/firebase/FirebasePlugin.java @@ -61,7 +61,7 @@ * FirestoreRetrieverConfig.builder() * .name("my-docs") * .collection("documents") - * .embedderName("googleai/text-embedding-004") + * .embedderName("googleai/gemini-embedding-001") * .vectorField("embedding") * .contentField("content") * .build()) diff --git a/plugins/firebase/src/main/java/com/google/genkit/plugins/firebase/retriever/FirestoreRetrieverConfig.java b/plugins/firebase/src/main/java/com/google/genkit/plugins/firebase/retriever/FirestoreRetrieverConfig.java index ef29ce9a3..dd76b0b74 100644 --- a/plugins/firebase/src/main/java/com/google/genkit/plugins/firebase/retriever/FirestoreRetrieverConfig.java +++ b/plugins/firebase/src/main/java/com/google/genkit/plugins/firebase/retriever/FirestoreRetrieverConfig.java @@ -34,7 +34,7 @@ * FirestoreRetrieverConfig config = FirestoreRetrieverConfig.builder() * .name("my-docs") * .collection("documents") - * .embedderName("googleai/text-embedding-004") + * .embedderName("googleai/gemini-embedding-001") * .vectorField("embedding") * .contentField("content") * .distanceMeasure(DistanceMeasure.COSINE) @@ -328,7 +328,7 @@ public Builder embedder(Embedder embedder) { /** * Sets the embedder name for resolution from registry. * - * @param embedderName the embedder name (e.g., "googleai/text-embedding-004") + * @param embedderName the embedder name (e.g., "googleai/gemini-embedding-001") * @return this builder */ public Builder embedderName(String embedderName) { diff --git a/plugins/google-genai/src/main/java/com/google/genkit/plugins/googlegenai/GeminiModel.java b/plugins/google-genai/src/main/java/com/google/genkit/plugins/googlegenai/GeminiModel.java index 60d818cd0..ad4133fea 100644 --- a/plugins/google-genai/src/main/java/com/google/genkit/plugins/googlegenai/GeminiModel.java +++ b/plugins/google-genai/src/main/java/com/google/genkit/plugins/googlegenai/GeminiModel.java @@ -63,7 +63,7 @@ public class GeminiModel implements Model { /** * Creates a new GeminiModel. * - * @param modelName the model name (e.g., "gemini-2.0-flash", "gemini-2.5-pro") + * @param modelName the model name (e.g., "gemini-2.5-flash", "gemini-2.5-pro") * @param options the plugin options */ public GeminiModel(String modelName, GoogleGenAIPluginOptions options) { diff --git a/plugins/mongodb/pom.xml b/plugins/mongodb/pom.xml new file mode 100644 index 000000000..d7e8dba19 --- /dev/null +++ b/plugins/mongodb/pom.xml @@ -0,0 +1,80 @@ + + + + 4.0.0 + + + com.google.genkit + genkit-parent + 1.0.0-SNAPSHOT + ../../pom.xml + + + genkit-plugin-mongodb + jar + Genkit MongoDB Plugin + MongoDB integration for Genkit - agent session persistence backed by MongoDB + + + false + 5.8.0 + + + + + + com.google.genkit + genkit-core + ${project.version} + + + com.google.genkit + genkit-ai + ${project.version} + + + + + org.mongodb + mongodb-driver-sync + ${mongodb.version} + + + + + org.slf4j + slf4j-api + + + + + org.junit.jupiter + junit-jupiter + test + + + org.mockito + mockito-core + test + + + diff --git a/plugins/mongodb/src/main/java/com/google/genkit/plugins/mongodb/MongoPlugin.java b/plugins/mongodb/src/main/java/com/google/genkit/plugins/mongodb/MongoPlugin.java new file mode 100644 index 000000000..accf1f4e0 --- /dev/null +++ b/plugins/mongodb/src/main/java/com/google/genkit/plugins/mongodb/MongoPlugin.java @@ -0,0 +1,183 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed 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. + * + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.google.genkit.plugins.mongodb; + +import com.google.genkit.ai.Embedder; +import com.google.genkit.core.Action; +import com.google.genkit.core.ActionType; +import com.google.genkit.core.Plugin; +import com.google.genkit.core.Registry; +import com.mongodb.client.MongoClient; +import com.mongodb.client.MongoClients; +import java.util.ArrayList; +import java.util.List; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * MongoDB Atlas Vector Search plugin for Genkit. + * + *

Registers a retriever and indexer named {@code mongodb/} for each configured + * collection, backed by an Atlas Vector Search index. Requires MongoDB Atlas or the {@code + * mongodb/mongodb-atlas-local} Docker image (a plain MongoDB server does not support {@code + * $vectorSearch}). + * + *

Example usage: + * + *

{@code
+ * Genkit genkit = Genkit.builder()
+ *     .plugin(GoogleGenAIPlugin.create(apiKey))
+ *     .plugin(
+ *         MongoPlugin.builder()
+ *             .connectionString("mongodb://localhost:27017/?directConnection=true")
+ *             .addCollection(
+ *                 MongoVectorStoreConfig.builder()
+ *                     .collectionName("films")
+ *                     .embedderName("googleai/gemini-embedding-001")
+ *                     .dimension(768)
+ *                     .createIndexIfNotExists(true)
+ *                     .build())
+ *             .build())
+ *     .build();
+ * }
+ */ +public final class MongoPlugin implements Plugin { + + /** The plugin name; used as the {@code mongodb/...} action prefix. */ + public static final String PLUGIN_NAME = "mongodb"; + + private static final Logger logger = LoggerFactory.getLogger(MongoPlugin.class); + + private final String connectionString; + private final MongoClient externalClient; + private final List collectionConfigs; + private MongoClient client; + + private MongoPlugin(Builder builder) { + this.connectionString = builder.connectionString; + this.externalClient = builder.externalClient; + this.collectionConfigs = new ArrayList<>(builder.collectionConfigs); + } + + /** + * Creates a new builder. + * + * @return a new builder + */ + public static Builder builder() { + return new Builder(); + } + + @Override + public String getName() { + return PLUGIN_NAME; + } + + @Override + public List> init() { + throw new IllegalStateException( + "MongoPlugin requires a Registry to resolve embedders. Use init(registry) instead."); + } + + @Override + public List> init(Registry registry) { + client = externalClient != null ? externalClient : MongoClients.create(connectionString); + + List> actions = new ArrayList<>(); + for (MongoVectorStoreConfig config : collectionConfigs) { + String embedderKey = ActionType.EMBEDDER.keyFromName(config.getEmbedderName()); + Action embedderAction = registry.lookupAction(embedderKey); + if (embedderAction == null) { + throw new IllegalStateException( + "Embedder not found: " + + config.getEmbedderName() + + ". Make sure the embedder plugin is registered before MongoPlugin."); + } + if (!(embedderAction instanceof Embedder embedder)) { + throw new IllegalStateException( + "Action " + config.getEmbedderName() + " is not an Embedder"); + } + + MongoVectorStore store = new MongoVectorStore(client, config, embedder); + actions.add(store.createRetriever()); + actions.add(store.createIndexer()); + logger.info( + "Registered MongoDB vector store: {}/{}", PLUGIN_NAME, config.getCollectionName()); + } + return actions; + } + + /** Builder for {@link MongoPlugin}. */ + public static final class Builder { + private String connectionString; + private MongoClient externalClient; + private final List collectionConfigs = new ArrayList<>(); + + private Builder() {} + + /** + * Sets the MongoDB connection string (required unless an external client is provided). + * + * @param connectionString the connection string + * @return this builder + */ + public Builder connectionString(String connectionString) { + this.connectionString = connectionString; + return this; + } + + /** + * Sets an external MongoDB client to use instead of creating one from a connection string. + * + * @param client the client + * @return this builder + */ + public Builder client(MongoClient client) { + this.externalClient = client; + return this; + } + + /** + * Adds a collection configuration. + * + * @param config the collection configuration + * @return this builder + */ + public Builder addCollection(MongoVectorStoreConfig config) { + this.collectionConfigs.add(config); + return this; + } + + /** + * Builds the plugin. + * + * @return a new {@code MongoPlugin} + */ + public MongoPlugin build() { + if (externalClient == null && (connectionString == null || connectionString.isBlank())) { + throw new IllegalStateException( + "connectionString is required when not providing an external MongoClient"); + } + if (collectionConfigs.isEmpty()) { + throw new IllegalStateException("At least one collection configuration is required"); + } + return new MongoPlugin(this); + } + } +} diff --git a/plugins/mongodb/src/main/java/com/google/genkit/plugins/mongodb/MongoVectorStore.java b/plugins/mongodb/src/main/java/com/google/genkit/plugins/mongodb/MongoVectorStore.java new file mode 100644 index 000000000..431db234f --- /dev/null +++ b/plugins/mongodb/src/main/java/com/google/genkit/plugins/mongodb/MongoVectorStore.java @@ -0,0 +1,363 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed 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. + * + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.google.genkit.plugins.mongodb; + +import com.google.genkit.ai.Document; +import com.google.genkit.ai.EmbedRequest; +import com.google.genkit.ai.EmbedResponse; +import com.google.genkit.ai.Embedder; +import com.google.genkit.ai.Indexer; +import com.google.genkit.ai.IndexerRequest; +import com.google.genkit.ai.IndexerResponse; +import com.google.genkit.ai.Retriever; +import com.google.genkit.ai.RetrieverRequest; +import com.google.genkit.ai.RetrieverResponse; +import com.google.genkit.core.ActionContext; +import com.mongodb.MongoCommandException; +import com.mongodb.client.MongoClient; +import com.mongodb.client.MongoCollection; +import com.mongodb.client.MongoDatabase; +import com.mongodb.client.model.Filters; +import com.mongodb.client.model.ReplaceOptions; +import com.mongodb.client.model.SearchIndexModel; +import com.mongodb.client.model.SearchIndexType; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import org.bson.conversions.Bson; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * MongoDB Atlas Vector Search-backed vector store. + * + *

Indexes documents into a collection with an embedding field and retrieves them with the {@code + * $vectorSearch} aggregation stage. Requires an Atlas Vector Search index; use the {@code + * mongodb/mongodb-atlas-local} Docker image for local development or a MongoDB Atlas cluster. + */ +public final class MongoVectorStore { + + private static final Logger logger = LoggerFactory.getLogger(MongoVectorStore.class); + private static final String SCORE_FIELD = "__score"; + private static final int INDEX_READY_TIMEOUT_SECONDS = 120; + + private final MongoCollection collection; + private final MongoVectorStoreConfig config; + private final Embedder embedder; + private final MongoDatabase database; + private boolean initialized = false; + + /** + * Creates a new store. + * + * @param client the MongoDB client + * @param config the collection configuration + * @param embedder the embedder used to vectorize documents and queries + */ + public MongoVectorStore(MongoClient client, MongoVectorStoreConfig config, Embedder embedder) { + this.config = config; + this.embedder = embedder; + this.database = client.getDatabase(config.getDatabaseName()); + this.collection = database.getCollection(config.getCollectionName()); + } + + /** Creates the retriever action registered by the plugin. */ + Retriever createRetriever() { + String name = MongoPlugin.PLUGIN_NAME + "/" + config.getCollectionName(); + return Retriever.builder().name(name).handler(this::retrieve).build(); + } + + /** Creates the indexer action registered by the plugin. */ + Indexer createIndexer() { + String name = MongoPlugin.PLUGIN_NAME + "/" + config.getCollectionName(); + return Indexer.builder().name(name).handler(this::index).build(); + } + + private synchronized void ensureInitialized() { + if (initialized) { + return; + } + if (config.isCreateIndexIfNotExists()) { + ensureCollection(); + ensureVectorIndex(); + } + initialized = true; + logger.info("MongoDB vector store initialized for collection: {}", config.getCollectionName()); + } + + private void ensureCollection() { + boolean exists = false; + for (String name : database.listCollectionNames()) { + if (name.equals(config.getCollectionName())) { + exists = true; + break; + } + } + if (!exists) { + database.createCollection(config.getCollectionName()); + } + } + + private void ensureVectorIndex() { + if (!searchIndexExists()) { + Bson definition = + new org.bson.Document( + "fields", + List.of( + new org.bson.Document("type", "vector") + .append("path", config.getEmbeddingField()) + .append("numDimensions", resolveDimension()) + .append("similarity", config.getSimilarity().getValue()))); + createSearchIndexWithRetry(definition); + logger.info("Creating Atlas Vector Search index: {}", config.getIndexName()); + } + waitForIndexReady(); + } + + /** + * Resolves the embedding dimension by probing the embedder, falling back to the configured + * dimension if the probe fails. This keeps the created index in sync with whatever embedding + * model is wired in. + */ + private int resolveDimension() { + try { + return generateEmbedding(null, "genkit dimension probe").size(); + } catch (RuntimeException e) { + logger.debug( + "Embedding probe failed; using configured dimension {}: {}", + config.getDimension(), + e.getMessage()); + return config.getDimension(); + } + } + + /** + * Returns whether the configured vector index already exists, retrying while the Atlas Search + * service ({@code mongot}) is still starting up (error 125). The service can lag behind {@code + * mongod} readiness, especially with the local Atlas image. + */ + private boolean searchIndexExists() { + for (org.bson.Document idx : listSearchIndexesWithRetry()) { + if (config.getIndexName().equals(idx.getString("name"))) { + return true; + } + } + return false; + } + + private void createSearchIndexWithRetry(Bson definition) { + long deadline = System.currentTimeMillis() + INDEX_READY_TIMEOUT_SECONDS * 1000L; + while (true) { + try { + collection.createSearchIndexes( + List.of( + new SearchIndexModel( + config.getIndexName(), definition, SearchIndexType.vectorSearch()))); + return; + } catch (MongoCommandException e) { + if (isSearchServiceUnavailable(e) && System.currentTimeMillis() < deadline) { + logger.info("Waiting for Atlas Search service before creating the vector index..."); + sleep(2000); + continue; + } + throw e; + } + } + } + + private List listSearchIndexesWithRetry() { + long deadline = System.currentTimeMillis() + INDEX_READY_TIMEOUT_SECONDS * 1000L; + while (true) { + try { + List out = new java.util.ArrayList<>(); + for (org.bson.Document idx : collection.listSearchIndexes()) { + out.add(idx); + } + return out; + } catch (MongoCommandException e) { + if (isSearchServiceUnavailable(e) && System.currentTimeMillis() < deadline) { + logger.info("Waiting for Atlas Search service to become available..."); + sleep(2000); + continue; + } + throw e; + } + } + } + + private static boolean isSearchServiceUnavailable(MongoCommandException e) { + return e.getErrorCode() == 125 + || (e.getErrorMessage() != null + && e.getErrorMessage().contains("Search Index Management service")); + } + + private static void sleep(long millis) { + try { + Thread.sleep(millis); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new RuntimeException("Interrupted while waiting for the Atlas Search service", e); + } + } + + private void waitForIndexReady() { + long deadline = System.currentTimeMillis() + INDEX_READY_TIMEOUT_SECONDS * 1000L; + while (System.currentTimeMillis() < deadline) { + for (org.bson.Document idx : listSearchIndexesWithRetry()) { + if (config.getIndexName().equals(idx.getString("name")) + && Boolean.TRUE.equals(idx.getBoolean("queryable"))) { + logger.info("Atlas Vector Search index {} is queryable", config.getIndexName()); + return; + } + } + sleep(2000); + } + throw new RuntimeException( + "Timed out waiting for Atlas Vector Search index " + config.getIndexName()); + } + + /** + * Retrieves documents similar to the query using the {@code $vectorSearch} aggregation stage. + * + * @param context the action context + * @param request the retriever request + * @return the retriever response with matching documents + */ + public RetrieverResponse retrieve(ActionContext context, RetrieverRequest request) { + ensureInitialized(); + Document queryDoc = request.getQuery(); + if (queryDoc == null || queryDoc.text() == null || queryDoc.text().isBlank()) { + throw new RuntimeException("Query document has no text content"); + } + int topK = + request.getOptions() != null && request.getOptions().getK() != null + ? request.getOptions().getK() + : 10; + List queryVector = generateEmbedding(context, queryDoc.text()); + int numCandidates = Math.max(config.getNumCandidates(), topK * 10); + + List pipeline = + List.of( + new org.bson.Document( + "$vectorSearch", + new org.bson.Document("index", config.getIndexName()) + .append("path", config.getEmbeddingField()) + .append("queryVector", queryVector) + .append("numCandidates", numCandidates) + .append("limit", topK)), + new org.bson.Document( + "$addFields", + new org.bson.Document( + SCORE_FIELD, new org.bson.Document("$meta", "vectorSearchScore")))); + + List documents = new ArrayList<>(); + for (org.bson.Document result : collection.aggregate(pipeline)) { + documents.add(toDocument(result)); + } + logger.debug( + "Retrieved {} documents from collection {}", documents.size(), config.getCollectionName()); + return new RetrieverResponse(documents); + } + + /** + * Indexes documents into the collection, generating an embedding for each. + * + * @param context the action context + * @param request the indexer request + * @return the indexer response + */ + public IndexerResponse index(ActionContext context, IndexerRequest request) { + ensureInitialized(); + List documents = request.getDocuments(); + if (documents == null || documents.isEmpty()) { + logger.warn("No documents to index"); + return new IndexerResponse(); + } + int indexed = 0; + for (Document doc : documents) { + String content = doc.text() != null ? doc.text() : ""; + List embedding = generateEmbedding(context, content); + String id = getOrGenerateId(doc); + + org.bson.Document stored = new org.bson.Document("_id", id); + stored.put(config.getTextField(), content); + stored.put(config.getEmbeddingField(), embedding); + if (doc.getMetadata() != null) { + for (Map.Entry entry : doc.getMetadata().entrySet()) { + if (!"id".equals(entry.getKey())) { + stored.put(entry.getKey(), entry.getValue()); + } + } + } + for (Map.Entry entry : config.getAdditionalMetadata().entrySet()) { + stored.put(entry.getKey(), entry.getValue()); + } + collection.replaceOne(Filters.eq("_id", id), stored, new ReplaceOptions().upsert(true)); + indexed++; + } + logger.info("Indexed {} documents into collection {}", indexed, config.getCollectionName()); + return new IndexerResponse(); + } + + private Document toDocument(org.bson.Document result) { + Map metadata = new HashMap<>(); + String content = ""; + for (Map.Entry entry : result.entrySet()) { + String key = entry.getKey(); + if (key.equals(config.getTextField())) { + content = entry.getValue() != null ? entry.getValue().toString() : ""; + } else if (key.equals(config.getEmbeddingField()) || key.equals(SCORE_FIELD)) { + continue; + } else if (key.equals("_id")) { + metadata.put("id", entry.getValue() != null ? entry.getValue().toString() : null); + } else { + metadata.put(key, entry.getValue()); + } + } + Object score = result.get(SCORE_FIELD); + if (score instanceof Number number) { + metadata.put("score", number.doubleValue()); + } + Document doc = new Document(content); + doc.setMetadata(metadata); + return doc; + } + + private List generateEmbedding(ActionContext ctx, String text) { + EmbedResponse response = embedder.run(ctx, new EmbedRequest(List.of(new Document(text)))); + if (response.getEmbeddings() == null || response.getEmbeddings().isEmpty()) { + throw new RuntimeException("Failed to generate embedding for text"); + } + float[] values = response.getEmbeddings().get(0).getValues(); + List out = new ArrayList<>(values.length); + for (float v : values) { + out.add((double) v); + } + return out; + } + + private String getOrGenerateId(Document doc) { + if (doc.getMetadata() != null && doc.getMetadata().get("id") != null) { + return doc.getMetadata().get("id").toString(); + } + return UUID.randomUUID().toString(); + } +} diff --git a/plugins/mongodb/src/main/java/com/google/genkit/plugins/mongodb/MongoVectorStoreConfig.java b/plugins/mongodb/src/main/java/com/google/genkit/plugins/mongodb/MongoVectorStoreConfig.java new file mode 100644 index 000000000..41f937457 --- /dev/null +++ b/plugins/mongodb/src/main/java/com/google/genkit/plugins/mongodb/MongoVectorStoreConfig.java @@ -0,0 +1,355 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed 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. + * + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.google.genkit.plugins.mongodb; + +import java.util.HashMap; +import java.util.Map; + +/** + * Configuration for a single MongoDB Atlas Vector Search collection managed by {@link MongoPlugin}. + * + *

Each config registers a retriever and indexer named {@code mongodb/} backed by + * an Atlas Vector Search index over the {@link #getEmbeddingField() embedding field}. + */ +public final class MongoVectorStoreConfig { + + /** Vector similarity function supported by Atlas Vector Search. */ + public enum Similarity { + COSINE("cosine"), + EUCLIDEAN("euclidean"), + DOT_PRODUCT("dotProduct"); + + private final String value; + + Similarity(String value) { + this.value = value; + } + + /** + * Returns the Atlas Vector Search similarity name. + * + * @return the similarity name + */ + public String getValue() { + return value; + } + } + + private final String databaseName; + private final String collectionName; + private final String embedderName; + private final String indexName; + private final int dimension; + private final Similarity similarity; + private final String textField; + private final String embeddingField; + private final int numCandidates; + private final boolean createIndexIfNotExists; + private final Map additionalMetadata; + + private MongoVectorStoreConfig(Builder builder) { + this.databaseName = builder.databaseName; + this.collectionName = builder.collectionName; + this.embedderName = builder.embedderName; + this.indexName = builder.indexName; + this.dimension = builder.dimension; + this.similarity = builder.similarity; + this.textField = builder.textField; + this.embeddingField = builder.embeddingField; + this.numCandidates = builder.numCandidates; + this.createIndexIfNotExists = builder.createIndexIfNotExists; + this.additionalMetadata = new HashMap<>(builder.additionalMetadata); + } + + /** + * Returns the database name (default {@code genkit}). + * + * @return the database name + */ + public String getDatabaseName() { + return databaseName; + } + + /** + * Returns the collection name. + * + * @return the collection name + */ + public String getCollectionName() { + return collectionName; + } + + /** + * Returns the name of the embedder used to vectorize documents and queries. + * + * @return the embedder name + */ + public String getEmbedderName() { + return embedderName; + } + + /** + * Returns the Atlas Vector Search index name (default {@code genkit_vector_index}). + * + * @return the index name + */ + public String getIndexName() { + return indexName; + } + + /** + * Returns the embedding dimension (default {@code 768}). + * + * @return the embedding dimension + */ + public int getDimension() { + return dimension; + } + + /** + * Returns the vector similarity function (default {@link Similarity#COSINE}). + * + * @return the similarity + */ + public Similarity getSimilarity() { + return similarity; + } + + /** + * Returns the field that stores the document text (default {@code text}). + * + * @return the text field name + */ + public String getTextField() { + return textField; + } + + /** + * Returns the field that stores the embedding vector (default {@code embedding}). + * + * @return the embedding field name + */ + public String getEmbeddingField() { + return embeddingField; + } + + /** + * Returns the number of nearest neighbors to consider during the vector search (default {@code + * 100}). Atlas recommends a value at least 10× the requested result count. + * + * @return the number of candidates + */ + public int getNumCandidates() { + return numCandidates; + } + + /** + * Returns whether to create the Atlas Vector Search index on first use if it does not exist + * (default {@code false}). + * + * @return {@code true} if the index should be created when missing + */ + public boolean isCreateIndexIfNotExists() { + return createIndexIfNotExists; + } + + /** + * Returns additional metadata merged into every indexed document. + * + * @return the additional metadata + */ + public Map getAdditionalMetadata() { + return additionalMetadata; + } + + /** + * Creates a new builder. + * + * @return a new builder + */ + public static Builder builder() { + return new Builder(); + } + + /** Builder for {@link MongoVectorStoreConfig}. */ + public static final class Builder { + private String databaseName = "genkit"; + private String collectionName; + private String embedderName; + private String indexName = "genkit_vector_index"; + private int dimension = 768; + private Similarity similarity = Similarity.COSINE; + private String textField = "text"; + private String embeddingField = "embedding"; + private int numCandidates = 100; + private boolean createIndexIfNotExists = false; + private final Map additionalMetadata = new HashMap<>(); + + private Builder() {} + + /** + * Sets the database name. + * + * @param databaseName the database name + * @return this builder + */ + public Builder databaseName(String databaseName) { + this.databaseName = databaseName; + return this; + } + + /** + * Sets the collection name. + * + * @param collectionName the collection name + * @return this builder + */ + public Builder collectionName(String collectionName) { + this.collectionName = collectionName; + return this; + } + + /** + * Sets the embedder name. + * + * @param embedderName the embedder name + * @return this builder + */ + public Builder embedderName(String embedderName) { + this.embedderName = embedderName; + return this; + } + + /** + * Sets the Atlas Vector Search index name. + * + * @param indexName the index name + * @return this builder + */ + public Builder indexName(String indexName) { + this.indexName = indexName; + return this; + } + + /** + * Sets the embedding dimension. + * + * @param dimension the embedding dimension (must be {@code >= 1}) + * @return this builder + */ + public Builder dimension(int dimension) { + this.dimension = dimension; + return this; + } + + /** + * Sets the vector similarity function. + * + * @param similarity the similarity + * @return this builder + */ + public Builder similarity(Similarity similarity) { + this.similarity = similarity; + return this; + } + + /** + * Sets the text field name. + * + * @param textField the text field name + * @return this builder + */ + public Builder textField(String textField) { + this.textField = textField; + return this; + } + + /** + * Sets the embedding field name. + * + * @param embeddingField the embedding field name + * @return this builder + */ + public Builder embeddingField(String embeddingField) { + this.embeddingField = embeddingField; + return this; + } + + /** + * Sets the number of nearest neighbors to consider during the vector search. + * + * @param numCandidates the number of candidates (must be {@code >= 1}) + * @return this builder + */ + public Builder numCandidates(int numCandidates) { + this.numCandidates = numCandidates; + return this; + } + + /** + * Sets whether to create the Atlas Vector Search index on first use if it does not exist. + * + * @param createIndexIfNotExists whether to create the index when missing + * @return this builder + */ + public Builder createIndexIfNotExists(boolean createIndexIfNotExists) { + this.createIndexIfNotExists = createIndexIfNotExists; + return this; + } + + /** + * Adds a metadata entry merged into every indexed document. + * + * @param key the metadata key + * @param value the metadata value + * @return this builder + */ + public Builder addAdditionalMetadata(String key, Object value) { + this.additionalMetadata.put(key, value); + return this; + } + + /** + * Builds a new {@code MongoVectorStoreConfig}. + * + * @return a new config instance + */ + public MongoVectorStoreConfig build() { + if (databaseName == null || databaseName.isBlank()) { + throw new IllegalArgumentException("databaseName must be non-empty"); + } + if (collectionName == null || collectionName.isBlank()) { + throw new IllegalArgumentException("collectionName must be non-empty"); + } + if (embedderName == null || embedderName.isBlank()) { + throw new IllegalArgumentException("embedderName must be non-empty"); + } + if (indexName == null || indexName.isBlank()) { + throw new IllegalArgumentException("indexName must be non-empty"); + } + if (dimension < 1) { + throw new IllegalArgumentException("dimension must be >= 1"); + } + if (numCandidates < 1) { + throw new IllegalArgumentException("numCandidates must be >= 1"); + } + return new MongoVectorStoreConfig(this); + } + } +} diff --git a/plugins/mongodb/src/main/java/com/google/genkit/plugins/mongodb/package-info.java b/plugins/mongodb/src/main/java/com/google/genkit/plugins/mongodb/package-info.java new file mode 100644 index 000000000..f73db19df --- /dev/null +++ b/plugins/mongodb/src/main/java/com/google/genkit/plugins/mongodb/package-info.java @@ -0,0 +1,26 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed 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. + * + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * MongoDB integration for Genkit. + * + *

{@link com.google.genkit.plugins.mongodb.MongoPlugin} registers MongoDB Atlas Vector Search + * retrievers and indexers for RAG workflows. Agent session persistence lives in the {@link + * com.google.genkit.plugins.mongodb.session} subpackage. + */ +package com.google.genkit.plugins.mongodb; diff --git a/plugins/mongodb/src/main/java/com/google/genkit/plugins/mongodb/session/MongoSessionStore.java b/plugins/mongodb/src/main/java/com/google/genkit/plugins/mongodb/session/MongoSessionStore.java new file mode 100644 index 000000000..be788130e --- /dev/null +++ b/plugins/mongodb/src/main/java/com/google/genkit/plugins/mongodb/session/MongoSessionStore.java @@ -0,0 +1,723 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed 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. + * + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.google.genkit.plugins.mongodb.session; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.NullNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.google.genkit.ai.agent.AgentFinishReason; +import com.google.genkit.ai.agent.GetSnapshotOptions; +import com.google.genkit.ai.agent.RuntimeError; +import com.google.genkit.ai.agent.SessionSnapshot; +import com.google.genkit.ai.agent.SessionState; +import com.google.genkit.ai.agent.SessionStore; +import com.google.genkit.ai.agent.SessionStoreOptions; +import com.google.genkit.ai.agent.SnapshotMutator; +import com.google.genkit.ai.agent.SnapshotStatus; +import com.google.genkit.ai.agent.SnapshotSubscriber; +import com.google.genkit.ai.agent.internal.SnapshotSharding; +import com.google.genkit.core.GenkitException; +import com.google.genkit.core.JsonUtils; +import com.google.genkit.core.jsonpatch.JsonPatch; +import com.mongodb.ErrorCategory; +import com.mongodb.MongoWriteException; +import com.mongodb.client.MongoClient; +import com.mongodb.client.MongoCollection; +import com.mongodb.client.model.Filters; +import com.mongodb.client.model.ReplaceOptions; +import com.mongodb.client.result.UpdateResult; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; +import java.util.function.Consumer; +import org.bson.Document; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * MongoDB-backed implementation of {@link SessionStore} and {@link SnapshotSubscriber}. + * + *

Persists session snapshots with the same sharded checkpoint + diff + pointer layout as the + * Firestore, DynamoDB, Cosmos DB, and PostgreSQL backends (see {@code FirestoreSessionStore}), + * sharing the pure logic in {@link SnapshotSharding}. + * + *

Storage layout (single collection)

+ * + *

All documents live in one collection (default database {@code genkit}, collection {@code + * genkit_sessions}). Each document's {@code _id} is {@code ::} where {@code + * prefix} is the per-tenant prefix (default {@code "global"}) and {@code recordId} discriminates + * the record kind: + * + *

    + *
  • {@code SNAP_} — one metadata document per snapshot. {@code kind} is {@code + * "checkpoint"} or {@code "diff"}; carries {@code checkpointId}, {@code + * checkpointShardCount}, {@code segmentPath}, {@code statePatch} (RFC-6902 as a JSON string + * for diffs) and {@code error} (JSON string). + *
  • {@code SHARD__} — a shard of the checkpoint state JSON. + *
  • {@code PTR_} — the current leaf pointer for a session. + *
+ * + *

Concurrency

+ * + *

Shard and snapshot documents are idempotent by {@code _id} (upserted); updating an existing + * snapshot id uses a {@code version} conditional replace, re-applying the (pure) mutator on + * conflict. The session pointer is advanced monotonically (never backward) under the same {@code + * version} concurrency. Shards are written before the snapshot document, which is written before + * the pointer flips, so a reader following the pointer always sees complete data. + * + * @param the type of custom session state + */ +public final class MongoSessionStore implements SessionStore, SnapshotSubscriber { + + private static final Logger logger = LoggerFactory.getLogger(MongoSessionStore.class); + private static final ObjectMapper MAPPER = JsonUtils.getObjectMapper(); + + static final String KIND_CHECKPOINT = "checkpoint"; + static final String KIND_DIFF = "diff"; + + private static final int MAX_ATTEMPTS = 5; + + private final MongoCollection collection; + private final MongoSessionStoreOptions options; + private final ScheduledExecutorService scheduler; + + /** + * Creates a store with default options. + * + * @param client the MongoDB client + */ + public MongoSessionStore(MongoClient client) { + this(client, MongoSessionStoreOptions.defaults()); + } + + /** + * Creates a store. + * + * @param client the MongoDB client + * @param options the store options + */ + public MongoSessionStore(MongoClient client, MongoSessionStoreOptions options) { + if (client == null) { + throw new IllegalArgumentException("MongoClient must be non-null"); + } + if (options == null) { + throw new IllegalArgumentException("options must be non-null"); + } + this.options = options; + this.collection = + client.getDatabase(options.getDatabaseName()).getCollection(options.getCollectionName()); + this.scheduler = + Executors.newSingleThreadScheduledExecutor( + r -> { + Thread t = new Thread(r, "genkit-mongo-session-store-poll"); + t.setDaemon(true); + return t; + }); + } + + // ────────────────────────────────────────────────────────────────────────── + // Id helpers + // ────────────────────────────────────────────────────────────────────────── + + private String prefix(SessionStoreOptions opts) { + String p = + options.getSnapshotPathPrefix().apply(opts != null ? opts : SessionStoreOptions.empty()); + return (p == null || p.isBlank()) ? "global" : p; + } + + private static String key(String prefix, String recordId) { + return prefix + "::" + recordId; + } + + private static String snapId(String snapshotId) { + return "SNAP_" + snapshotId; + } + + private static String shardId(String checkpointId, int index) { + return "SHARD_" + checkpointId + "_" + index; + } + + private static String ptrId(String sessionId) { + return "PTR_" + sessionId; + } + + // ────────────────────────────────────────────────────────────────────────── + // SnapshotWriter + // ────────────────────────────────────────────────────────────────────────── + + /** + * {@inheritDoc} + * + *

Implements the same identity/sessionId/status defaulting contract as the reference stores, + * then writes the snapshot (checkpoint shards + metadata document) and advances the session + * pointer. + */ + @Override + public String saveSnapshot( + String snapshotId, SnapshotMutator mutator, SessionStoreOptions storeOpts) { + String prefix = prefix(storeOpts); + + for (int attempt = 1; ; attempt++) { + // 1. Read existing snapshot (+ version) and reconstruct state. + SessionSnapshot existing = null; + String existingSessionId = null; + Long existingVersion = null; + if (snapshotId != null) { + SnapshotSharding.validateId(snapshotId); + Row row = readRow(prefix, snapId(snapshotId)); + if (row != null) { + existing = readSnapshot(prefix, row.doc); + existingSessionId = existing.getSessionId(); + existingVersion = row.version; + } + } + + // 2. Apply mutator (pure — safe to re-run on conflict retry). + SessionSnapshot result = mutator.apply(existing); + if (result == null) { + return null; + } + + // 3. Identity / sessionId / status defaulting (mirror the reference stores). + String finalId; + if (snapshotId != null) { + finalId = snapshotId; + } else if (result.getSnapshotId() != null && !result.getSnapshotId().isBlank()) { + finalId = result.getSnapshotId(); + } else { + finalId = UUID.randomUUID().toString(); + } + SnapshotSharding.validateId(finalId); + result.setSnapshotId(finalId); + + if (existingSessionId != null) { + result.setSessionId(existingSessionId); + } + if (result.getSessionId() == null && result.getState() != null) { + result.setSessionId(result.getState().getSessionId()); + } + if (result.getSessionId() == null || result.getSessionId().isBlank()) { + throw GenkitException.builder() + .message("snapshot requires sessionId") + .errorCode("INVALID_ARGUMENT") + .build(); + } + if (result.getStatus() == null) { + result.setStatus(SnapshotStatus.COMPLETED); + } + + // 4. Resolve parent metadata to decide checkpoint vs diff. + String parentId = result.getParentId(); + JsonNode newState = stateToJson(result.getState()); + + ParentInfo parent = null; + if (parentId != null && !parentId.isBlank()) { + Row parentRow = readRow(prefix, snapId(parentId)); + if (parentRow != null) { + parent = loadParentInfo(prefix, parentRow.doc); + } + } + + boolean parentExists = parent != null; + int depthFromCheckpoint = 0; + JsonNode statePatch = null; + int diffSizeBytes = 0; + if (parent != null) { + depthFromCheckpoint = parent.segmentPath.size() + 1; + statePatch = JsonPatch.diff(parent.state, newState); + diffSizeBytes = jsonBytes(statePatch); + } + + boolean checkpoint = + SnapshotSharding.shouldCheckpoint( + parentExists, + depthFromCheckpoint, + options.getCheckpointInterval(), + diffSizeBytes, + options.getShardSize()); + + // 5. Build the snapshot metadata document (+ shard docs for a checkpoint). + ObjectNode doc = baseDoc(result); + String checkpointId; + int checkpointShardCount; + List segmentPath; + + if (checkpoint) { + checkpointId = finalId; + String stateJson = writeJson(newState); + List shards = SnapshotSharding.shardString(stateJson, options.getShardSize()); + checkpointShardCount = shards.size(); + segmentPath = new ArrayList<>(); + for (int i = 0; i < shards.size(); i++) { + ObjectNode shardDoc = MAPPER.createObjectNode(); + shardDoc.put("checkpointId", checkpointId); + shardDoc.put("index", i); + shardDoc.put("data", shards.get(i)); + upsertRow(prefix, shardId(checkpointId, i), shardDoc); + } + doc.put("kind", KIND_CHECKPOINT); + } else { + ParentInfo p = parent; + if (p == null) { + throw new GenkitException("internal: diff path without parent"); + } + checkpointId = p.checkpointId; + checkpointShardCount = p.checkpointShardCount; + segmentPath = new ArrayList<>(p.segmentPath); + segmentPath.add(finalId); + doc.put("kind", KIND_DIFF); + doc.put("statePatch", writeJson(statePatch)); + } + + doc.put("checkpointId", checkpointId); + doc.put("checkpointShardCount", checkpointShardCount); + putStringArray(doc, "segmentPath", segmentPath); + + // 6. Write the snapshot document with optimistic concurrency. + boolean written = writeConditional(prefix, snapId(finalId), doc, existingVersion); + if (!written) { + if (attempt < MAX_ATTEMPTS) { + continue; // concurrent writer won the race; re-read and retry the pure mutator. + } + throw new GenkitException("Failed to save snapshot after " + MAX_ATTEMPTS + " attempts"); + } + + // 7. Advance the session pointer (never backward). + advancePointer( + prefix, + result.getSessionId(), + finalId, + result.getCreatedAt(), + result.getUpdatedAt(), + checkpointId, + checkpointShardCount, + segmentPath); + + return finalId; + } + } + + /** Advances the session pointer to the new leaf unless the stored pointer is already newer. */ + private void advancePointer( + String prefix, + String sessionId, + String snapshotId, + String createdAt, + String updatedAt, + String checkpointId, + int checkpointShardCount, + List segmentPath) { + String id = ptrId(sessionId); + + for (int attempt = 1; ; attempt++) { + Row existing = readRow(prefix, id); + Long version = existing != null ? existing.version : null; + if (existing != null) { + String currentLeaf = getString(existing.doc, "currentSnapshotId"); + String currentCreatedAt = getString(existing.doc, "currentCreatedAt"); + boolean sameLeaf = snapshotId.equals(currentLeaf); + boolean newer = + createdAt == null + || currentCreatedAt == null + || createdAt.compareTo(currentCreatedAt) >= 0; + if (!sameLeaf && !newer) { + return; // stored pointer is already newer; don't move backward. + } + } + + ObjectNode ptr = MAPPER.createObjectNode(); + ptr.put("currentSnapshotId", snapshotId); + ptr.put("checkpointId", checkpointId); + ptr.put("checkpointShardCount", checkpointShardCount); + putStringArray(ptr, "segmentPath", segmentPath); + if (createdAt != null) { + ptr.put("currentCreatedAt", createdAt); + } + if (updatedAt != null) { + ptr.put("updatedAt", updatedAt); + } + + if (writeConditional(prefix, id, ptr, version)) { + return; + } + if (attempt >= MAX_ATTEMPTS) { + logger.debug("Pointer for session {} not advanced (lost concurrency race)", sessionId); + return; + } + } + } + + // ────────────────────────────────────────────────────────────────────────── + // SnapshotReader + // ────────────────────────────────────────────────────────────────────────── + + /** + * {@inheritDoc} + * + *

By {@code snapshotId}: loads the snapshot document and reconstructs its state from the + * checkpoint shards + ordered {@code segmentPath} diffs. By {@code sessionId}: reads the pointer, + * then loads and reconstructs the pointed snapshot. + */ + @Override + public SessionSnapshot getSnapshot(GetSnapshotOptions opts) { + if (opts == null) { + return null; + } + String prefix = prefix(SessionStoreOptions.empty()); + + if (opts.getSnapshotId() != null) { + SnapshotSharding.validateId(opts.getSnapshotId()); + Row row = readRow(prefix, snapId(opts.getSnapshotId())); + return row == null ? null : readSnapshot(prefix, row.doc); + } + if (opts.getSessionId() != null) { + SnapshotSharding.validateId(opts.getSessionId()); + Row pointer = readRow(prefix, ptrId(opts.getSessionId())); + if (pointer == null) { + return null; + } + String leafId = getString(pointer.doc, "currentSnapshotId"); + if (leafId == null) { + return null; + } + Row row = readRow(prefix, snapId(leafId)); + return row == null ? null : readSnapshot(prefix, row.doc); + } + return null; + } + + // ────────────────────────────────────────────────────────────────────────── + // SnapshotSubscriber (polling) + // ────────────────────────────────────────────────────────────────────────── + + /** + * {@inheritDoc} + * + *

The subscription polls {@link #getSnapshot} on a shared daemon scheduler and fires the + * callback whenever the serialized snapshot content changes. The callback also fires immediately + * if the snapshot already exists. + */ + @Override + public AutoCloseable onSnapshotStateChange( + String snapshotId, Consumer> cb, SessionStoreOptions storeOpts) { + SnapshotSharding.validateId(snapshotId); + GetSnapshotOptions get = GetSnapshotOptions.builder().snapshotId(snapshotId).build(); + + final String[] lastContent = {null}; + SessionSnapshot initial = getSnapshot(get); + if (initial != null) { + lastContent[0] = serializeQuietly(initial); + cb.accept(initial); + } + + ScheduledFuture future = + scheduler.scheduleAtFixedRate( + () -> { + try { + SessionSnapshot snap = getSnapshot(get); + if (snap == null) { + return; + } + String content = serializeQuietly(snap); + if (!content.equals(lastContent[0])) { + lastContent[0] = content; + cb.accept(snap); + } + } catch (Exception e) { + // Swallow poll errors — don't kill the scheduler thread. + } + }, + options.getPollIntervalMs(), + options.getPollIntervalMs(), + TimeUnit.MILLISECONDS); + + return () -> future.cancel(false); + } + + // ────────────────────────────────────────────────────────────────────────── + // Document (de)serialization + // ────────────────────────────────────────────────────────────────────────── + + /** Builds the non-state base document for a snapshot (metadata only). */ + private ObjectNode baseDoc(SessionSnapshot snap) { + ObjectNode data = MAPPER.createObjectNode(); + data.put("snapshotId", snap.getSnapshotId()); + data.put("sessionId", snap.getSessionId()); + if (snap.getParentId() != null) { + data.put("parentId", snap.getParentId()); + } + if (snap.getCreatedAt() != null) { + data.put("createdAt", snap.getCreatedAt()); + } + if (snap.getUpdatedAt() != null) { + data.put("updatedAt", snap.getUpdatedAt()); + } + if (snap.getHeartbeatAt() != null) { + data.put("heartbeatAt", snap.getHeartbeatAt()); + } + if (snap.getStatus() != null) { + data.put("status", snap.getStatus().getValue()); + } + if (snap.getFinishReason() != null) { + data.put("finishReason", snap.getFinishReason().getValue()); + } + if (snap.getError() != null) { + data.put("error", writeJson(snap.getError())); + } + return data; + } + + /** Holds the reconstructed parent state and its checkpoint lineage. */ + private static final class ParentInfo { + JsonNode state; + String checkpointId; + int checkpointShardCount; + List segmentPath; + } + + /** Loads a parent snapshot's checkpoint lineage and reconstructs its state. */ + private ParentInfo loadParentInfo(String prefix, ObjectNode doc) { + ParentInfo info = new ParentInfo(); + info.checkpointId = getString(doc, "checkpointId"); + info.checkpointShardCount = getInt(doc, "checkpointShardCount"); + info.segmentPath = getStringList(doc, "segmentPath"); + info.state = + reconstructFullState( + prefix, info.checkpointId, info.checkpointShardCount, info.segmentPath); + return info; + } + + /** Reads and fully reconstructs a snapshot from its metadata document. */ + private SessionSnapshot readSnapshot(String prefix, ObjectNode doc) { + String checkpointId = getString(doc, "checkpointId"); + int checkpointShardCount = getInt(doc, "checkpointShardCount"); + List segmentPath = getStringList(doc, "segmentPath"); + JsonNode state = reconstructFullState(prefix, checkpointId, checkpointShardCount, segmentPath); + return docToSnapshot(doc, state); + } + + /** + * Reconstructs full state: loads the checkpoint shards (concatenate, parse) then applies the + * {@code segmentPath} diffs in order. + */ + private JsonNode reconstructFullState( + String prefix, String checkpointId, int checkpointShardCount, List segmentPath) { + if (checkpointId == null) { + return NullNode.getInstance(); + } + List shardContents = new ArrayList<>(); + for (int i = 0; i < checkpointShardCount; i++) { + Row shard = readRow(prefix, shardId(checkpointId, i)); + shardContents.add(shard != null ? getString(shard.doc, "data") : ""); + } + String checkpointJson = SnapshotSharding.reassembleShards(shardContents); + + List diffs = new ArrayList<>(); + for (String diffId : segmentPath) { + Row diffRow = readRow(prefix, snapId(diffId)); + if (diffRow != null) { + String patch = getString(diffRow.doc, "statePatch"); + if (patch != null) { + diffs.add(patch); + } + } + } + try { + return SnapshotSharding.reconstructState(checkpointJson, diffs); + } catch (Exception e) { + throw new GenkitException("Failed to reconstruct session state: " + e.getMessage(), e); + } + } + + /** Builds a {@link SessionSnapshot} from a metadata document and reconstructed state. */ + @SuppressWarnings("unchecked") + private SessionSnapshot docToSnapshot(ObjectNode doc, JsonNode state) { + SessionSnapshot.Builder builder = SessionSnapshot.builder(); + builder.snapshotId(getString(doc, "snapshotId")); + builder.sessionId(getString(doc, "sessionId")); + builder.parentId(getString(doc, "parentId")); + builder.createdAt(getString(doc, "createdAt")); + builder.updatedAt(getString(doc, "updatedAt")); + builder.heartbeatAt(getString(doc, "heartbeatAt")); + String status = getString(doc, "status"); + if (status != null) { + builder.status(SnapshotStatus.fromValueOrCompleted(status)); + } + String finishReason = getString(doc, "finishReason"); + if (finishReason != null) { + builder.finishReason(AgentFinishReason.fromValue(finishReason)); + } + String errorJson = getString(doc, "error"); + if (errorJson != null) { + try { + builder.error(MAPPER.readValue(errorJson, RuntimeError.class)); + } catch (Exception e) { + throw new GenkitException("Failed to parse snapshot error: " + e.getMessage(), e); + } + } + if (state != null && !state.isNull()) { + try { + builder.state((SessionState) MAPPER.treeToValue(state, SessionState.class)); + } catch (Exception e) { + throw new GenkitException("Failed to parse session state: " + e.getMessage(), e); + } + } + return builder.build(); + } + + /** Serializes session state to a JSON node (null state → JSON null). */ + private JsonNode stateToJson(SessionState state) { + if (state == null) { + return NullNode.getInstance(); + } + return MAPPER.valueToTree(state); + } + + // ────────────────────────────────────────────────────────────────────────── + // Low-level MongoDB + node helpers + // ────────────────────────────────────────────────────────────────────────── + + /** A single stored document: its JSON payload and optimistic-concurrency version. */ + private static final class Row { + final ObjectNode doc; + final long version; + + Row(ObjectNode doc, long version) { + this.doc = doc; + this.version = version; + } + } + + /** Reads a document by key, or {@code null} when it does not exist. */ + private Row readRow(String prefix, String id) { + Document found = collection.find(Filters.eq("_id", key(prefix, id))).first(); + if (found == null) { + return null; + } + long version = found.get("version") instanceof Number n ? n.longValue() : 1L; + Document payload = new Document(found); + payload.remove("_id"); + payload.remove("pk"); + payload.remove("version"); + try { + return new Row((ObjectNode) MAPPER.readTree(payload.toJson()), version); + } catch (Exception e) { + throw new GenkitException("Failed to read session document " + id + ": " + e.getMessage(), e); + } + } + + /** Builds the persisted document by merging the payload with the id/pk/version envelope. */ + private Document toDocument(String prefix, String id, ObjectNode payload, long version) { + Document doc = Document.parse(writeJson(payload)); + doc.put("_id", key(prefix, id)); + doc.put("pk", prefix); + doc.put("version", version); + return doc; + } + + /** Idempotently upserts a document (used for shard records). */ + private void upsertRow(String prefix, String id, ObjectNode payload) { + Document doc = toDocument(prefix, id, payload, 1L); + collection.replaceOne( + Filters.eq("_id", key(prefix, id)), doc, new ReplaceOptions().upsert(true)); + } + + /** + * Writes a document with optimistic concurrency: inserts when {@code expectedVersion} is {@code + * null}, otherwise replaces only when the stored version still matches. Returns {@code false} + * when a concurrent writer won the race (the caller re-reads and retries the pure mutator). + */ + private boolean writeConditional( + String prefix, String id, ObjectNode payload, Long expectedVersion) { + if (expectedVersion == null) { + Document doc = toDocument(prefix, id, payload, 1L); + try { + collection.insertOne(doc); + return true; + } catch (MongoWriteException e) { + if (e.getError().getCategory() == ErrorCategory.DUPLICATE_KEY) { + return false; // concurrent insert won the race. + } + throw new GenkitException( + "Failed to insert session document " + id + ": " + e.getMessage(), e); + } + } + Document doc = toDocument(prefix, id, payload, expectedVersion + 1); + UpdateResult result = + collection.replaceOne( + Filters.and(Filters.eq("_id", key(prefix, id)), Filters.eq("version", expectedVersion)), + doc); + return result.getMatchedCount() == 1; + } + + private static void putStringArray(ObjectNode node, String name, List values) { + ArrayNode arr = node.putArray(name); + for (String v : values) { + arr.add(v); + } + } + + private static String getString(ObjectNode node, String name) { + JsonNode v = node.get(name); + return (v == null || v.isNull()) ? null : v.asText(); + } + + private static int getInt(ObjectNode node, String name) { + JsonNode v = node.get(name); + return (v == null || v.isNull()) ? 0 : v.asInt(); + } + + private static List getStringList(ObjectNode node, String name) { + List out = new ArrayList<>(); + JsonNode v = node.get(name); + if (v != null && v.isArray()) { + for (JsonNode e : v) { + out.add(e.asText()); + } + } + return out; + } + + private static String writeJson(Object value) { + try { + return MAPPER.writeValueAsString(value); + } catch (Exception e) { + throw new GenkitException("Failed to serialize value: " + e.getMessage(), e); + } + } + + private static int jsonBytes(Object value) { + return writeJson(value).getBytes(StandardCharsets.UTF_8).length; + } + + private static String serializeQuietly(SessionSnapshot snap) { + try { + return MAPPER.writeValueAsString(MAPPER.valueToTree(snap)); + } catch (Exception e) { + return ""; + } + } +} diff --git a/plugins/mongodb/src/main/java/com/google/genkit/plugins/mongodb/session/MongoSessionStoreOptions.java b/plugins/mongodb/src/main/java/com/google/genkit/plugins/mongodb/session/MongoSessionStoreOptions.java new file mode 100644 index 000000000..c8edf97da --- /dev/null +++ b/plugins/mongodb/src/main/java/com/google/genkit/plugins/mongodb/session/MongoSessionStoreOptions.java @@ -0,0 +1,249 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed 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. + * + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.google.genkit.plugins.mongodb.session; + +import com.google.genkit.ai.agent.SessionStoreOptions; +import java.util.function.Function; + +/** + * Configuration for {@link MongoSessionStore}. + * + *

The store persists all records in a single MongoDB collection (default database {@value + * #DEFAULT_DATABASE}, collection {@value #DEFAULT_COLLECTION}). Each document's {@code _id} + * combines the per-tenant prefix (default {@code "global"}) with the record id; the record kind is + * discriminated by the id. Each document carries a {@code version} field used for optimistic + * concurrency. + * + *

The default {@link #getShardSize()} is {@value #DEFAULT_SHARD_SIZE} bytes, kept safely under + * MongoDB's 16 MB document-size limit; because the store forces a checkpoint whenever a diff + * would exceed the shard size, diff documents stay bounded too. + */ +public final class MongoSessionStoreOptions { + + /** Default database name. */ + public static final String DEFAULT_DATABASE = "genkit"; + + /** Default collection name. */ + public static final String DEFAULT_COLLECTION = "genkit_sessions"; + + /** Default number of turns between full checkpoints. */ + public static final int DEFAULT_CHECKPOINT_INTERVAL = 25; + + /** Default shard size in bytes for checkpoint state (1 MiB, under the 16 MB document cap). */ + public static final int DEFAULT_SHARD_SIZE = 1024 * 1024; + + /** Default subscription poll interval in milliseconds. */ + public static final long DEFAULT_POLL_INTERVAL_MS = 2000L; + + private final String databaseName; + private final String collectionName; + private final int checkpointInterval; + private final int shardSize; + private final Function snapshotPathPrefix; + private final long pollIntervalMs; + + private MongoSessionStoreOptions(Builder builder) { + this.databaseName = builder.databaseName; + this.collectionName = builder.collectionName; + this.checkpointInterval = builder.checkpointInterval; + this.shardSize = builder.shardSize; + this.snapshotPathPrefix = builder.snapshotPathPrefix; + this.pollIntervalMs = builder.pollIntervalMs; + } + + /** + * Returns the MongoDB database name (default {@value #DEFAULT_DATABASE}). + * + * @return the database name + */ + public String getDatabaseName() { + return databaseName; + } + + /** + * Returns the MongoDB collection name (default {@value #DEFAULT_COLLECTION}). + * + * @return the collection name + */ + public String getCollectionName() { + return collectionName; + } + + /** + * Returns the number of turns between full checkpoints (default {@value + * #DEFAULT_CHECKPOINT_INTERVAL}). + * + * @return the checkpoint interval + */ + public int getCheckpointInterval() { + return checkpointInterval; + } + + /** + * Returns the shard size in bytes for checkpoint state (default {@value #DEFAULT_SHARD_SIZE}). + * + * @return the shard size in bytes + */ + public int getShardSize() { + return shardSize; + } + + /** + * Returns the function that derives the per-tenant prefix from the per-request store options + * (default {@code o -> "global"}). + * + * @return the prefix function + */ + public Function getSnapshotPathPrefix() { + return snapshotPathPrefix; + } + + /** + * Returns the subscription poll interval in milliseconds (default {@value + * #DEFAULT_POLL_INTERVAL_MS}). + * + * @return the poll interval in milliseconds + */ + public long getPollIntervalMs() { + return pollIntervalMs; + } + + /** + * Returns default options. + * + * @return a {@code MongoSessionStoreOptions} with all defaults + */ + public static MongoSessionStoreOptions defaults() { + return builder().build(); + } + + /** + * Creates a new builder. + * + * @return a new builder + */ + public static Builder builder() { + return new Builder(); + } + + /** Builder for {@link MongoSessionStoreOptions}. */ + public static final class Builder { + private String databaseName = DEFAULT_DATABASE; + private String collectionName = DEFAULT_COLLECTION; + private int checkpointInterval = DEFAULT_CHECKPOINT_INTERVAL; + private int shardSize = DEFAULT_SHARD_SIZE; + private Function snapshotPathPrefix = o -> "global"; + private long pollIntervalMs = DEFAULT_POLL_INTERVAL_MS; + + private Builder() {} + + /** + * Sets the MongoDB database name. + * + * @param databaseName the database name + * @return this builder + */ + public Builder databaseName(String databaseName) { + this.databaseName = databaseName; + return this; + } + + /** + * Sets the MongoDB collection name. + * + * @param collectionName the collection name + * @return this builder + */ + public Builder collectionName(String collectionName) { + this.collectionName = collectionName; + return this; + } + + /** + * Sets the number of turns between full checkpoints. + * + * @param checkpointInterval the checkpoint interval (must be {@code >= 1}) + * @return this builder + */ + public Builder checkpointInterval(int checkpointInterval) { + this.checkpointInterval = checkpointInterval; + return this; + } + + /** + * Sets the shard size in bytes for checkpoint state (must stay under the 16 MB document cap). + * + * @param shardSize the shard size in bytes (must be {@code >= 1}) + * @return this builder + */ + public Builder shardSize(int shardSize) { + this.shardSize = shardSize; + return this; + } + + /** + * Sets the function that derives the per-tenant prefix. + * + * @param snapshotPathPrefix the prefix function + * @return this builder + */ + public Builder snapshotPathPrefix(Function snapshotPathPrefix) { + this.snapshotPathPrefix = snapshotPathPrefix; + return this; + } + + /** + * Sets the subscription poll interval in milliseconds. + * + * @param pollIntervalMs the poll interval (must be {@code >= 1}) + * @return this builder + */ + public Builder pollIntervalMs(long pollIntervalMs) { + this.pollIntervalMs = pollIntervalMs; + return this; + } + + /** + * Builds a new {@code MongoSessionStoreOptions}. + * + * @return a new options instance + */ + public MongoSessionStoreOptions build() { + if (databaseName == null || databaseName.isBlank()) { + throw new IllegalArgumentException("databaseName must be non-empty"); + } + if (collectionName == null || collectionName.isBlank()) { + throw new IllegalArgumentException("collectionName must be non-empty"); + } + if (checkpointInterval < 1) { + throw new IllegalArgumentException("checkpointInterval must be >= 1"); + } + if (shardSize < 1) { + throw new IllegalArgumentException("shardSize must be >= 1"); + } + if (snapshotPathPrefix == null) { + throw new IllegalArgumentException("snapshotPathPrefix must be non-null"); + } + if (pollIntervalMs < 1) { + throw new IllegalArgumentException("pollIntervalMs must be >= 1"); + } + return new MongoSessionStoreOptions(this); + } + } +} diff --git a/plugins/mongodb/src/main/java/com/google/genkit/plugins/mongodb/session/package-info.java b/plugins/mongodb/src/main/java/com/google/genkit/plugins/mongodb/session/package-info.java new file mode 100644 index 000000000..1c379b533 --- /dev/null +++ b/plugins/mongodb/src/main/java/com/google/genkit/plugins/mongodb/session/package-info.java @@ -0,0 +1,28 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed 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. + * + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * MongoDB-backed agent session persistence. + * + *

{@link com.google.genkit.plugins.mongodb.session.MongoSessionStore} implements the Genkit + * {@code SessionStore} contract using the sharded checkpoint + RFC-6902 diff + pointer layout + * shared with the Firestore, DynamoDB, Cosmos DB, and PostgreSQL backends. Construct it directly + * from a {@code com.mongodb.client.MongoClient} and pass it to an agent via {@code + * AgentConfig.store(...)}. + */ +package com.google.genkit.plugins.mongodb.session; diff --git a/plugins/mongodb/src/test/java/com/google/genkit/plugins/mongodb/MongoPluginTest.java b/plugins/mongodb/src/test/java/com/google/genkit/plugins/mongodb/MongoPluginTest.java new file mode 100644 index 000000000..4ff62455d --- /dev/null +++ b/plugins/mongodb/src/test/java/com/google/genkit/plugins/mongodb/MongoPluginTest.java @@ -0,0 +1,65 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed 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. + * + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.google.genkit.plugins.mongodb; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import org.junit.jupiter.api.Test; + +/** Unit tests for {@link MongoPlugin}. */ +class MongoPluginTest { + + private static MongoVectorStoreConfig config() { + return MongoVectorStoreConfig.builder().collectionName("films").embedderName("e").build(); + } + + @Test + void getName() { + MongoPlugin plugin = + MongoPlugin.builder() + .connectionString("mongodb://localhost:27017") + .addCollection(config()) + .build(); + assertEquals("mongodb", plugin.getName()); + } + + @Test + void requiresConnectionStringOrClient() { + assertThrows( + IllegalStateException.class, () -> MongoPlugin.builder().addCollection(config()).build()); + } + + @Test + void requiresAtLeastOneCollection() { + assertThrows( + IllegalStateException.class, + () -> MongoPlugin.builder().connectionString("mongodb://localhost:27017").build()); + } + + @Test + void initWithoutRegistryThrows() { + MongoPlugin plugin = + MongoPlugin.builder() + .connectionString("mongodb://localhost:27017") + .addCollection(config()) + .build(); + assertThrows(IllegalStateException.class, plugin::init); + } +} diff --git a/plugins/mongodb/src/test/java/com/google/genkit/plugins/mongodb/MongoVectorStoreConfigTest.java b/plugins/mongodb/src/test/java/com/google/genkit/plugins/mongodb/MongoVectorStoreConfigTest.java new file mode 100644 index 000000000..2ee54c263 --- /dev/null +++ b/plugins/mongodb/src/test/java/com/google/genkit/plugins/mongodb/MongoVectorStoreConfigTest.java @@ -0,0 +1,94 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed 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. + * + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.google.genkit.plugins.mongodb; + +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 org.junit.jupiter.api.Test; + +/** Unit tests for {@link MongoVectorStoreConfig}. */ +class MongoVectorStoreConfigTest { + + @Test + void defaultsAreSane() { + MongoVectorStoreConfig c = + MongoVectorStoreConfig.builder() + .collectionName("films") + .embedderName("googleai/gemini-embedding-001") + .build(); + assertEquals("genkit", c.getDatabaseName()); + assertEquals("films", c.getCollectionName()); + assertEquals("genkit_vector_index", c.getIndexName()); + assertEquals(768, c.getDimension()); + assertEquals(MongoVectorStoreConfig.Similarity.COSINE, c.getSimilarity()); + assertEquals("cosine", c.getSimilarity().getValue()); + assertEquals("text", c.getTextField()); + assertEquals("embedding", c.getEmbeddingField()); + assertEquals(100, c.getNumCandidates()); + assertEquals(false, c.isCreateIndexIfNotExists()); + assertTrue(c.getAdditionalMetadata().isEmpty()); + } + + @Test + void customBuilder() { + MongoVectorStoreConfig c = + MongoVectorStoreConfig.builder() + .databaseName("rag") + .collectionName("docs") + .embedderName("e") + .indexName("idx") + .dimension(1536) + .similarity(MongoVectorStoreConfig.Similarity.DOT_PRODUCT) + .textField("content") + .embeddingField("vector") + .numCandidates(200) + .createIndexIfNotExists(true) + .addAdditionalMetadata("source", "wiki") + .build(); + assertEquals("rag", c.getDatabaseName()); + assertEquals("idx", c.getIndexName()); + assertEquals(1536, c.getDimension()); + assertEquals("dotProduct", c.getSimilarity().getValue()); + assertEquals("content", c.getTextField()); + assertEquals("vector", c.getEmbeddingField()); + assertEquals(200, c.getNumCandidates()); + assertEquals(true, c.isCreateIndexIfNotExists()); + assertEquals("wiki", c.getAdditionalMetadata().get("source")); + } + + @Test + void builderValidates() { + assertThrows( + IllegalArgumentException.class, + () -> MongoVectorStoreConfig.builder().embedderName("e").build()); + assertThrows( + IllegalArgumentException.class, + () -> MongoVectorStoreConfig.builder().collectionName("c").build()); + assertThrows( + IllegalArgumentException.class, + () -> + MongoVectorStoreConfig.builder() + .collectionName("c") + .embedderName("e") + .dimension(0) + .build()); + } +} diff --git a/plugins/mongodb/src/test/java/com/google/genkit/plugins/mongodb/MongoVectorStoreTest.java b/plugins/mongodb/src/test/java/com/google/genkit/plugins/mongodb/MongoVectorStoreTest.java new file mode 100644 index 000000000..0d2386fc0 --- /dev/null +++ b/plugins/mongodb/src/test/java/com/google/genkit/plugins/mongodb/MongoVectorStoreTest.java @@ -0,0 +1,138 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed 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. + * + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.google.genkit.plugins.mongodb; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assumptions.assumeTrue; + +import com.google.genkit.ai.Document; +import com.google.genkit.ai.EmbedResponse; +import com.google.genkit.ai.Embedder; +import com.google.genkit.ai.EmbedderInfo; +import com.google.genkit.ai.IndexerRequest; +import com.google.genkit.ai.RetrieverRequest; +import com.google.genkit.ai.RetrieverResponse; +import com.mongodb.client.MongoClient; +import com.mongodb.client.MongoClients; +import java.util.ArrayList; +import java.util.List; +import java.util.Random; +import java.util.UUID; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +/** + * Integration tests for {@link MongoVectorStore}, gated on the {@code MONGODB_ATLAS_URI} + * environment variable (a MongoDB Atlas cluster or the {@code mongodb/mongodb-atlas-local} Docker + * image). Skipped via {@link org.junit.jupiter.api.Assumptions} when unset. Uses a deterministic + * stub embedder so a query equal to an indexed document's text retrieves that document first. + */ +class MongoVectorStoreTest { + + private static final String URI = System.getenv("MONGODB_ATLAS_URI"); + private static final int DIM = 16; + + private MongoClient client; + private String database; + private String collection; + private MongoVectorStore store; + + private static boolean configured() { + return URI != null && !URI.isEmpty(); + } + + private static Embedder stubEmbedder() { + return new Embedder( + "test/stub", + new EmbedderInfo(), + (ctx, req) -> { + List out = new ArrayList<>(); + for (Document doc : req.getDocuments()) { + Random random = new Random(doc.text().hashCode()); + float[] values = new float[DIM]; + for (int i = 0; i < DIM; i++) { + values[i] = random.nextFloat(); + } + out.add(new EmbedResponse.Embedding(values)); + } + return new EmbedResponse(out); + }); + } + + @BeforeEach + void setUp() { + if (!configured()) { + return; + } + client = MongoClients.create(URI); + database = "genkit_test"; + collection = "vec_" + UUID.randomUUID().toString().replace("-", ""); + MongoVectorStoreConfig config = + MongoVectorStoreConfig.builder() + .databaseName(database) + .collectionName(collection) + .embedderName("test/stub") + .dimension(DIM) + .createIndexIfNotExists(true) + .build(); + store = new MongoVectorStore(client, config, stubEmbedder()); + } + + @AfterEach + void tearDown() { + if (client != null) { + if (database != null && collection != null) { + client.getDatabase(database).getCollection(collection).drop(); + } + client.close(); + } + } + + @Test + void indexThenRetrieveReturnsNearestFirst() throws Exception { + assumeTrue(configured()); + List docs = + List.of( + Document.fromText("The Matrix is a sci-fi film about simulated reality."), + Document.fromText("The Godfather is a crime film about a mafia family."), + Document.fromText("Inception is a sci-fi film about dreams.")); + store.index(null, new IndexerRequest(docs)); + + RetrieverRequest request = new RetrieverRequest(Document.fromText(docs.get(1).text())); + RetrieverRequest.RetrieverOptions options = new RetrieverRequest.RetrieverOptions(); + options.setK(3); + request.setOptions(options); + + // Newly indexed documents may take a moment to become searchable. + RetrieverResponse response = null; + for (int attempt = 0; attempt < 15; attempt++) { + response = store.retrieve(null, request); + if (!response.getDocuments().isEmpty()) { + break; + } + Thread.sleep(1000); + } + assertFalse(response.getDocuments().isEmpty()); + assertEquals( + "The Godfather is a crime film about a mafia family.", + response.getDocuments().get(0).text()); + } +} diff --git a/plugins/mongodb/src/test/java/com/google/genkit/plugins/mongodb/session/MongoSessionStoreOptionsTest.java b/plugins/mongodb/src/test/java/com/google/genkit/plugins/mongodb/session/MongoSessionStoreOptionsTest.java new file mode 100644 index 000000000..0dec9c964 --- /dev/null +++ b/plugins/mongodb/src/test/java/com/google/genkit/plugins/mongodb/session/MongoSessionStoreOptionsTest.java @@ -0,0 +1,78 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed 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. + * + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.google.genkit.plugins.mongodb.session; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import com.google.genkit.ai.agent.SessionStoreOptions; +import org.junit.jupiter.api.Test; + +/** Unit tests for {@link MongoSessionStoreOptions}. */ +class MongoSessionStoreOptionsTest { + + @Test + void defaultsAreSane() { + MongoSessionStoreOptions o = MongoSessionStoreOptions.defaults(); + assertEquals("genkit", o.getDatabaseName()); + assertEquals("genkit_sessions", o.getCollectionName()); + assertEquals(25, o.getCheckpointInterval()); + assertEquals(1024 * 1024, o.getShardSize()); + assertEquals("global", o.getSnapshotPathPrefix().apply(SessionStoreOptions.empty())); + assertEquals(2000L, o.getPollIntervalMs()); + } + + @Test + void customBuilder() { + MongoSessionStoreOptions o = + MongoSessionStoreOptions.builder() + .databaseName("my-db") + .collectionName("my_sessions") + .checkpointInterval(10) + .shardSize(4096) + .snapshotPathPrefix(so -> "tenant-1") + .pollIntervalMs(500) + .build(); + assertEquals("my-db", o.getDatabaseName()); + assertEquals("my_sessions", o.getCollectionName()); + assertEquals(10, o.getCheckpointInterval()); + assertEquals(4096, o.getShardSize()); + assertEquals("tenant-1", o.getSnapshotPathPrefix().apply(SessionStoreOptions.empty())); + assertEquals(500L, o.getPollIntervalMs()); + } + + @Test + void builderValidates() { + assertThrows( + IllegalArgumentException.class, + () -> MongoSessionStoreOptions.builder().databaseName("").build()); + assertThrows( + IllegalArgumentException.class, + () -> MongoSessionStoreOptions.builder().collectionName("").build()); + assertThrows( + IllegalArgumentException.class, + () -> MongoSessionStoreOptions.builder().checkpointInterval(0).build()); + assertThrows( + IllegalArgumentException.class, + () -> MongoSessionStoreOptions.builder().shardSize(0).build()); + assertThrows( + IllegalArgumentException.class, + () -> MongoSessionStoreOptions.builder().pollIntervalMs(0).build()); + } +} diff --git a/plugins/mongodb/src/test/java/com/google/genkit/plugins/mongodb/session/MongoSessionStoreTest.java b/plugins/mongodb/src/test/java/com/google/genkit/plugins/mongodb/session/MongoSessionStoreTest.java new file mode 100644 index 000000000..49d0d0f09 --- /dev/null +++ b/plugins/mongodb/src/test/java/com/google/genkit/plugins/mongodb/session/MongoSessionStoreTest.java @@ -0,0 +1,202 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed 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. + * + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.google.genkit.plugins.mongodb.session; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assumptions.assumeTrue; + +import com.google.genkit.ai.Message; +import com.google.genkit.ai.agent.GetSnapshotOptions; +import com.google.genkit.ai.agent.SessionSnapshot; +import com.google.genkit.ai.agent.SessionState; +import com.google.genkit.ai.agent.SessionStoreOptions; +import com.google.genkit.ai.agent.SnapshotStatus; +import com.google.genkit.core.GenkitException; +import com.mongodb.client.MongoClient; +import com.mongodb.client.MongoClients; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +/** + * Tests for {@link MongoSessionStore}. + * + *

Integration tests are gated on the {@code MONGO_URI} environment variable (e.g. a local + * MongoDB started with Docker). When it is unset the tests are skipped via {@link + * org.junit.jupiter.api.Assumptions}. + */ +class MongoSessionStoreTest { + + private static final String URI = System.getenv("MONGO_URI"); + + private MongoClient client; + private String database; + private String collection; + private MongoSessionStore> store; + + private static boolean configured() { + return URI != null && !URI.isEmpty(); + } + + @BeforeEach + void setUp() { + if (!configured()) { + return; // integration tests skip via assumeTrue + } + client = MongoClients.create(URI); + database = "genkit_test"; + collection = "genkit_sessions_test_" + UUID.randomUUID().toString().replace("-", ""); + store = + new MongoSessionStore<>( + client, + MongoSessionStoreOptions.builder() + .databaseName(database) + .collectionName(collection) + .checkpointInterval(3) + .build()); + } + + @AfterEach + void tearDown() { + if (client != null) { + if (database != null && collection != null) { + client.getDatabase(database).getCollection(collection).drop(); + } + client.close(); + } + } + + private static SessionSnapshot> snapshotWithState( + String sessionId, String parentId, Map custom) { + SessionState> state = + SessionState.>builder() + .sessionId(sessionId) + .messages(List.of(Message.user("hello"))) + .custom(custom) + .build(); + return SessionSnapshot.>builder() + .sessionId(sessionId) + .parentId(parentId) + .status(SnapshotStatus.COMPLETED) + .state(state) + .build(); + } + + @Test + void saveThenGetBySnapshotIdRoundTrips() { + assumeTrue(configured()); + String sessionId = "s-" + UUID.randomUUID(); + Map custom = new HashMap<>(); + custom.put("count", 1); + + String id = + store.saveSnapshot( + null, e -> snapshotWithState(sessionId, null, custom), SessionStoreOptions.empty()); + assertNotNull(id); + + SessionSnapshot> got = + store.getSnapshot(GetSnapshotOptions.builder().snapshotId(id).build()); + assertNotNull(got); + assertEquals(sessionId, got.getSessionId()); + assertEquals(SnapshotStatus.COMPLETED, got.getStatus()); + assertEquals(1, got.getState().getMessages().size()); + assertEquals(1, ((Number) got.getState().getCustom().get("count")).intValue()); + } + + @Test + void getBySessionIdReturnsLeaf() { + assumeTrue(configured()); + String sessionId = "s-" + UUID.randomUUID(); + + Map c1 = new HashMap<>(); + c1.put("count", 1); + String id1 = + store.saveSnapshot( + null, e -> snapshotWithState(sessionId, null, c1), SessionStoreOptions.empty()); + Map c2 = new HashMap<>(); + c2.put("count", 2); + String id2 = + store.saveSnapshot( + null, e -> snapshotWithState(sessionId, id1, c2), SessionStoreOptions.empty()); + + SessionSnapshot> latest = + store.getSnapshot(GetSnapshotOptions.builder().sessionId(sessionId).build()); + assertNotNull(latest); + assertEquals(id2, latest.getSnapshotId()); + assertEquals(2, ((Number) latest.getState().getCustom().get("count")).intValue()); + } + + @Test + void diffThenCheckpointReconstructs() { + assumeTrue(configured()); + // checkpointInterval is 3; save 5 turns across checkpoint boundaries and confirm the leaf + // reconstructs correctly (checkpoint shards + segment-path diffs). + String sessionId = "s-" + UUID.randomUUID(); + String parent = null; + String lastId = null; + for (int i = 1; i <= 5; i++) { + Map c = new HashMap<>(); + c.put("count", i); + final String p = parent; + lastId = + store.saveSnapshot( + null, e -> snapshotWithState(sessionId, p, c), SessionStoreOptions.empty()); + parent = lastId; + } + SessionSnapshot> got = + store.getSnapshot(GetSnapshotOptions.builder().snapshotId(lastId).build()); + assertNotNull(got); + assertEquals(5, ((Number) got.getState().getCustom().get("count")).intValue()); + } + + @Test + void rejectsEmptySessionId() { + assumeTrue(configured()); + GenkitException ex = + assertThrows( + GenkitException.class, + () -> + store.saveSnapshot( + null, + e -> snapshotWithState("", null, new HashMap<>()), + SessionStoreOptions.empty())); + assertEquals("INVALID_ARGUMENT", ex.getErrorCode()); + } + + @Test + void mutatorNullIsNoOp() { + assumeTrue(configured()); + assertNull(store.saveSnapshot(null, e -> null, SessionStoreOptions.empty())); + } + + @Test + void getUnknownSessionReturnsNull() { + assumeTrue(configured()); + assertNull( + store.getSnapshot( + GetSnapshotOptions.builder().sessionId("s-" + UUID.randomUUID()).build())); + } +} diff --git a/plugins/pinecone/README.md b/plugins/pinecone/README.md index d5c49172c..2300e4a9e 100644 --- a/plugins/pinecone/README.md +++ b/plugins/pinecone/README.md @@ -45,7 +45,7 @@ Genkit genkit = Genkit.builder() .apiKey(System.getenv("PINECONE_API_KEY")) .addIndex(PineconeIndexConfig.builder() .indexName("my-index") - .embedderName("googleai/text-embedding-004") + .embedderName("googleai/gemini-embedding-001") .dimension(768) .build()) .build()) @@ -133,12 +133,12 @@ PineconePlugin plugin = PineconePlugin.builder() .addIndex(PineconeIndexConfig.builder() .indexName("my-index") .namespace("production") - .embedderName("googleai/text-embedding-004") + .embedderName("googleai/gemini-embedding-001") .build()) .addIndex(PineconeIndexConfig.builder() .indexName("my-index") .namespace("staging") - .embedderName("googleai/text-embedding-004") + .embedderName("googleai/gemini-embedding-001") .build()) .build(); @@ -171,7 +171,7 @@ var ragFlow = genkit.defineFlow("ragFlow", String.class, String.class, (context, // Generate response using LLM GenerateRequest generateRequest = GenerateRequest.builder() - .model("googleai/gemini-2.0-flash") + .model("googleai/gemini-2.5-flash") .messages(List.of( Message.builder() .role(Role.USER) @@ -241,7 +241,7 @@ PineconeIndexConfig.Cloud.AZURE ```java PineconeIndexConfig.builder() .indexName("new-index") - .embedderName("googleai/text-embedding-004") + .embedderName("googleai/gemini-embedding-001") .dimension(768) .metric(PineconeIndexConfig.Metric.COSINE) .cloud(PineconeIndexConfig.Cloud.AWS) diff --git a/plugins/pinecone/src/main/java/com/google/genkit/plugins/pinecone/PineconeIndexConfig.java b/plugins/pinecone/src/main/java/com/google/genkit/plugins/pinecone/PineconeIndexConfig.java index 6ee72fb1c..f2561c08c 100644 --- a/plugins/pinecone/src/main/java/com/google/genkit/plugins/pinecone/PineconeIndexConfig.java +++ b/plugins/pinecone/src/main/java/com/google/genkit/plugins/pinecone/PineconeIndexConfig.java @@ -31,7 +31,7 @@ *

{@code
  * PineconeIndexConfig config = PineconeIndexConfig.builder()
  *     .indexName("my-index")
- *     .embedderName("googleai/text-embedding-004")
+ *     .embedderName("googleai/gemini-embedding-001")
  *     .namespace("production")
  *     .build();
  * }
diff --git a/plugins/pinecone/src/main/java/com/google/genkit/plugins/pinecone/PineconePlugin.java b/plugins/pinecone/src/main/java/com/google/genkit/plugins/pinecone/PineconePlugin.java index f5b7c73ea..2848251db 100644 --- a/plugins/pinecone/src/main/java/com/google/genkit/plugins/pinecone/PineconePlugin.java +++ b/plugins/pinecone/src/main/java/com/google/genkit/plugins/pinecone/PineconePlugin.java @@ -45,7 +45,7 @@ * .addIndex( * PineconeIndexConfig.builder() * .indexName("my-index") - * .embedderName("googleai/text-embedding-004") + * .embedderName("googleai/gemini-embedding-001") * .build()) * .build()) * .build(); diff --git a/plugins/pinecone/src/main/java/com/google/genkit/plugins/pinecone/package-info.java b/plugins/pinecone/src/main/java/com/google/genkit/plugins/pinecone/package-info.java index 47a5f08c6..c591e6a8d 100644 --- a/plugins/pinecone/src/main/java/com/google/genkit/plugins/pinecone/package-info.java +++ b/plugins/pinecone/src/main/java/com/google/genkit/plugins/pinecone/package-info.java @@ -40,7 +40,7 @@ * .addIndex( * PineconeIndexConfig.builder() * .indexName("my-index") - * .embedderName("googleai/text-embedding-004") + * .embedderName("googleai/gemini-embedding-001") * .build()) * .build()) * .build(); diff --git a/plugins/postgresql/README.md b/plugins/postgresql/README.md index 5dca82e13..8304c9115 100644 --- a/plugins/postgresql/README.md +++ b/plugins/postgresql/README.md @@ -55,7 +55,7 @@ Genkit genkit = Genkit.builder() .password("pass") .addTable(PostgresTableConfig.builder() .tableName("documents") - .embedderName("googleai/text-embedding-004") + .embedderName("googleai/gemini-embedding-001") .vectorDimension(768) .build()) .build()) @@ -150,7 +150,7 @@ var ragFlow = genkit.defineFlow("ragFlow", String.class, String.class, (context, // Generate response using LLM GenerateRequest generateRequest = GenerateRequest.builder() - .model("googleai/gemini-2.0-flash") + .model("googleai/gemini-2.5-flash") .messages(List.of( Message.builder() .role(Role.USER) diff --git a/plugins/postgresql/src/main/java/com/google/genkit/plugins/postgresql/PostgresPlugin.java b/plugins/postgresql/src/main/java/com/google/genkit/plugins/postgresql/PostgresPlugin.java index cbac4d05e..78f22eea2 100644 --- a/plugins/postgresql/src/main/java/com/google/genkit/plugins/postgresql/PostgresPlugin.java +++ b/plugins/postgresql/src/main/java/com/google/genkit/plugins/postgresql/PostgresPlugin.java @@ -49,7 +49,7 @@ * .addTable( * PostgresTableConfig.builder() * .tableName("documents") - * .embedderName("googleai/text-embedding-004") + * .embedderName("googleai/gemini-embedding-001") * .build()) * .build()) * .build(); diff --git a/plugins/postgresql/src/main/java/com/google/genkit/plugins/postgresql/PostgresTableConfig.java b/plugins/postgresql/src/main/java/com/google/genkit/plugins/postgresql/PostgresTableConfig.java index 1df32c72a..8f22e1dab 100644 --- a/plugins/postgresql/src/main/java/com/google/genkit/plugins/postgresql/PostgresTableConfig.java +++ b/plugins/postgresql/src/main/java/com/google/genkit/plugins/postgresql/PostgresTableConfig.java @@ -31,7 +31,7 @@ *
{@code
  * PostgresTableConfig config = PostgresTableConfig.builder()
  *     .tableName("documents")
- *     .embedderName("googleai/text-embedding-004")
+ *     .embedderName("googleai/gemini-embedding-001")
  *     .vectorDimension(768)
  *     .distanceStrategy(DistanceStrategy.COSINE)
  *     .build();
diff --git a/plugins/postgresql/src/main/java/com/google/genkit/plugins/postgresql/package-info.java b/plugins/postgresql/src/main/java/com/google/genkit/plugins/postgresql/package-info.java
index d29ff495c..f06ef3f7b 100644
--- a/plugins/postgresql/src/main/java/com/google/genkit/plugins/postgresql/package-info.java
+++ b/plugins/postgresql/src/main/java/com/google/genkit/plugins/postgresql/package-info.java
@@ -42,7 +42,7 @@
  *             .addTable(
  *                 PostgresTableConfig.builder()
  *                     .tableName("documents")
- *                     .embedderName("googleai/text-embedding-004")
+ *                     .embedderName("googleai/gemini-embedding-001")
  *                     .build())
  *             .build())
  *     .build();
diff --git a/plugins/postgresql/src/main/java/com/google/genkit/plugins/postgresql/session/PostgresSessionStore.java b/plugins/postgresql/src/main/java/com/google/genkit/plugins/postgresql/session/PostgresSessionStore.java
new file mode 100644
index 000000000..c7834503c
--- /dev/null
+++ b/plugins/postgresql/src/main/java/com/google/genkit/plugins/postgresql/session/PostgresSessionStore.java
@@ -0,0 +1,761 @@
+/*
+ * Copyright 2025 Google LLC
+ *
+ * Licensed 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.
+ *
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package com.google.genkit.plugins.postgresql.session;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.node.ArrayNode;
+import com.fasterxml.jackson.databind.node.NullNode;
+import com.fasterxml.jackson.databind.node.ObjectNode;
+import com.google.genkit.ai.agent.AgentFinishReason;
+import com.google.genkit.ai.agent.GetSnapshotOptions;
+import com.google.genkit.ai.agent.RuntimeError;
+import com.google.genkit.ai.agent.SessionSnapshot;
+import com.google.genkit.ai.agent.SessionState;
+import com.google.genkit.ai.agent.SessionStore;
+import com.google.genkit.ai.agent.SessionStoreOptions;
+import com.google.genkit.ai.agent.SnapshotMutator;
+import com.google.genkit.ai.agent.SnapshotStatus;
+import com.google.genkit.ai.agent.SnapshotSubscriber;
+import com.google.genkit.ai.agent.internal.SnapshotSharding;
+import com.google.genkit.core.GenkitException;
+import com.google.genkit.core.JsonUtils;
+import com.google.genkit.core.jsonpatch.JsonPatch;
+import java.nio.charset.StandardCharsets;
+import java.sql.Connection;
+import java.sql.PreparedStatement;
+import java.sql.ResultSet;
+import java.sql.SQLException;
+import java.sql.Statement;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.UUID;
+import java.util.concurrent.Executors;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.ScheduledFuture;
+import java.util.concurrent.TimeUnit;
+import java.util.function.Consumer;
+import javax.sql.DataSource;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * PostgreSQL-backed implementation of {@link SessionStore} and {@link SnapshotSubscriber}.
+ *
+ * 

Persists session snapshots with the same sharded checkpoint + diff + pointer layout as the + * Firestore, DynamoDB, and Cosmos DB backends (see {@code FirestoreSessionStore}), sharing the pure + * logic in {@link SnapshotSharding}. + * + *

Storage layout (single table)

+ * + *

All records live in one table (default {@code genkit_sessions}) keyed by {@code (pk, id)}. The + * {@code pk} column holds the per-tenant prefix (default {@code "global"}); the {@code id} column + * discriminates the record kind, each row carrying a JSONB {@code doc} payload and a {@code + * version} counter: + * + *

    + *
  • {@code SNAP_} — one metadata record per snapshot. {@code kind} is {@code + * "checkpoint"} or {@code "diff"}; carries {@code checkpointId}, {@code + * checkpointShardCount}, {@code segmentPath}, {@code statePatch} (RFC-6902 as a JSON string + * for diffs) and {@code error} (JSON string). + *
  • {@code SHARD__} — a shard of the checkpoint state JSON. + *
  • {@code PTR_} — the current leaf pointer for a session. + *
+ * + *

Concurrency

+ * + *

Shard and snapshot records are idempotent by key (upserted); updating an existing snapshot id + * uses a {@code version} conditional update, re-applying the (pure) mutator on conflict. The + * session pointer is advanced monotonically (never backward) under the same {@code version} + * concurrency. Shards are written before the snapshot record, which is written before the pointer + * flips, so a reader following the pointer always sees complete data. + * + * @param the type of custom session state + */ +public final class PostgresSessionStore implements SessionStore, SnapshotSubscriber { + + private static final Logger logger = LoggerFactory.getLogger(PostgresSessionStore.class); + private static final ObjectMapper MAPPER = JsonUtils.getObjectMapper(); + + static final String KIND_CHECKPOINT = "checkpoint"; + static final String KIND_DIFF = "diff"; + + private static final int MAX_ATTEMPTS = 5; + + private final DataSource dataSource; + private final PostgresSessionStoreOptions options; + private final String table; + private final ScheduledExecutorService scheduler; + + /** + * Creates a store with default options. + * + * @param dataSource the PostgreSQL data source + */ + public PostgresSessionStore(DataSource dataSource) { + this(dataSource, PostgresSessionStoreOptions.defaults()); + } + + /** + * Creates a store. + * + * @param dataSource the PostgreSQL data source + * @param options the store options + */ + public PostgresSessionStore(DataSource dataSource, PostgresSessionStoreOptions options) { + if (dataSource == null) { + throw new IllegalArgumentException("DataSource must be non-null"); + } + if (options == null) { + throw new IllegalArgumentException("options must be non-null"); + } + this.dataSource = dataSource; + this.options = options; + this.table = quoteIdentifier(options.getTableName()); + if (options.isCreateTableIfNotExists()) { + ensureTable(); + } + this.scheduler = + Executors.newSingleThreadScheduledExecutor( + r -> { + Thread t = new Thread(r, "genkit-postgres-session-store-poll"); + t.setDaemon(true); + return t; + }); + } + + private void ensureTable() { + String sql = + "CREATE TABLE IF NOT EXISTS " + + table + + " (" + + "pk TEXT NOT NULL," + + "id TEXT NOT NULL," + + "doc JSONB NOT NULL," + + "version BIGINT NOT NULL DEFAULT 1," + + "PRIMARY KEY (pk, id))"; + try (Connection conn = dataSource.getConnection(); + Statement stmt = conn.createStatement()) { + stmt.execute(sql); + logger.info("PostgreSQL session store initialized for table: {}", options.getTableName()); + } catch (SQLException e) { + throw new GenkitException("Failed to create session table: " + e.getMessage(), e); + } + } + + // ────────────────────────────────────────────────────────────────────────── + // Id helpers + // ────────────────────────────────────────────────────────────────────────── + + private String prefix(SessionStoreOptions opts) { + String p = + options.getSnapshotPathPrefix().apply(opts != null ? opts : SessionStoreOptions.empty()); + return (p == null || p.isBlank()) ? "global" : p; + } + + private static String snapId(String snapshotId) { + return "SNAP_" + snapshotId; + } + + private static String shardId(String checkpointId, int index) { + return "SHARD_" + checkpointId + "_" + index; + } + + private static String ptrId(String sessionId) { + return "PTR_" + sessionId; + } + + // ────────────────────────────────────────────────────────────────────────── + // SnapshotWriter + // ────────────────────────────────────────────────────────────────────────── + + /** + * {@inheritDoc} + * + *

Implements the same identity/sessionId/status defaulting contract as the reference stores, + * then writes the snapshot (checkpoint shards + metadata record) and advances the session + * pointer. + */ + @Override + public String saveSnapshot( + String snapshotId, SnapshotMutator mutator, SessionStoreOptions storeOpts) { + String prefix = prefix(storeOpts); + + for (int attempt = 1; ; attempt++) { + // 1. Read existing snapshot (+ version) and reconstruct state. + SessionSnapshot existing = null; + String existingSessionId = null; + Long existingVersion = null; + if (snapshotId != null) { + SnapshotSharding.validateId(snapshotId); + Row row = readRow(prefix, snapId(snapshotId)); + if (row != null) { + existing = readSnapshot(prefix, row.doc); + existingSessionId = existing.getSessionId(); + existingVersion = row.version; + } + } + + // 2. Apply mutator (pure — safe to re-run on conflict retry). + SessionSnapshot result = mutator.apply(existing); + if (result == null) { + return null; + } + + // 3. Identity / sessionId / status defaulting (mirror the reference stores). + String finalId; + if (snapshotId != null) { + finalId = snapshotId; + } else if (result.getSnapshotId() != null && !result.getSnapshotId().isBlank()) { + finalId = result.getSnapshotId(); + } else { + finalId = UUID.randomUUID().toString(); + } + SnapshotSharding.validateId(finalId); + result.setSnapshotId(finalId); + + if (existingSessionId != null) { + result.setSessionId(existingSessionId); + } + if (result.getSessionId() == null && result.getState() != null) { + result.setSessionId(result.getState().getSessionId()); + } + if (result.getSessionId() == null || result.getSessionId().isBlank()) { + throw GenkitException.builder() + .message("snapshot requires sessionId") + .errorCode("INVALID_ARGUMENT") + .build(); + } + if (result.getStatus() == null) { + result.setStatus(SnapshotStatus.COMPLETED); + } + + // 4. Resolve parent metadata to decide checkpoint vs diff. + String parentId = result.getParentId(); + JsonNode newState = stateToJson(result.getState()); + + ParentInfo parent = null; + if (parentId != null && !parentId.isBlank()) { + Row parentRow = readRow(prefix, snapId(parentId)); + if (parentRow != null) { + parent = loadParentInfo(prefix, parentRow.doc); + } + } + + boolean parentExists = parent != null; + int depthFromCheckpoint = 0; + JsonNode statePatch = null; + int diffSizeBytes = 0; + if (parent != null) { + depthFromCheckpoint = parent.segmentPath.size() + 1; + statePatch = JsonPatch.diff(parent.state, newState); + diffSizeBytes = jsonBytes(statePatch); + } + + boolean checkpoint = + SnapshotSharding.shouldCheckpoint( + parentExists, + depthFromCheckpoint, + options.getCheckpointInterval(), + diffSizeBytes, + options.getShardSize()); + + // 5. Build the snapshot metadata record (+ shard rows for a checkpoint). + ObjectNode doc = baseDoc(result); + String checkpointId; + int checkpointShardCount; + List segmentPath; + + if (checkpoint) { + checkpointId = finalId; + String stateJson = writeJson(newState); + List shards = SnapshotSharding.shardString(stateJson, options.getShardSize()); + checkpointShardCount = shards.size(); + segmentPath = new ArrayList<>(); + for (int i = 0; i < shards.size(); i++) { + ObjectNode shardDoc = MAPPER.createObjectNode(); + shardDoc.put("checkpointId", checkpointId); + shardDoc.put("index", i); + shardDoc.put("data", shards.get(i)); + upsertRow(prefix, shardId(checkpointId, i), shardDoc); + } + doc.put("kind", KIND_CHECKPOINT); + } else { + ParentInfo p = parent; + if (p == null) { + throw new GenkitException("internal: diff path without parent"); + } + checkpointId = p.checkpointId; + checkpointShardCount = p.checkpointShardCount; + segmentPath = new ArrayList<>(p.segmentPath); + segmentPath.add(finalId); + doc.put("kind", KIND_DIFF); + doc.put("statePatch", writeJson(statePatch)); + } + + doc.put("checkpointId", checkpointId); + doc.put("checkpointShardCount", checkpointShardCount); + putStringArray(doc, "segmentPath", segmentPath); + + // 6. Write the snapshot record with optimistic concurrency. + boolean written = writeConditional(prefix, snapId(finalId), doc, existingVersion); + if (!written) { + if (attempt < MAX_ATTEMPTS) { + continue; // concurrent writer won the race; re-read and retry the pure mutator. + } + throw new GenkitException("Failed to save snapshot after " + MAX_ATTEMPTS + " attempts"); + } + + // 7. Advance the session pointer (never backward). + advancePointer( + prefix, + result.getSessionId(), + finalId, + result.getCreatedAt(), + result.getUpdatedAt(), + checkpointId, + checkpointShardCount, + segmentPath); + + return finalId; + } + } + + /** Advances the session pointer to the new leaf unless the stored pointer is already newer. */ + private void advancePointer( + String prefix, + String sessionId, + String snapshotId, + String createdAt, + String updatedAt, + String checkpointId, + int checkpointShardCount, + List segmentPath) { + String id = ptrId(sessionId); + + for (int attempt = 1; ; attempt++) { + Row existing = readRow(prefix, id); + Long version = existing != null ? existing.version : null; + if (existing != null) { + String currentLeaf = getString(existing.doc, "currentSnapshotId"); + String currentCreatedAt = getString(existing.doc, "currentCreatedAt"); + boolean sameLeaf = snapshotId.equals(currentLeaf); + boolean newer = + createdAt == null + || currentCreatedAt == null + || createdAt.compareTo(currentCreatedAt) >= 0; + if (!sameLeaf && !newer) { + return; // stored pointer is already newer; don't move backward. + } + } + + ObjectNode ptr = MAPPER.createObjectNode(); + ptr.put("currentSnapshotId", snapshotId); + ptr.put("checkpointId", checkpointId); + ptr.put("checkpointShardCount", checkpointShardCount); + putStringArray(ptr, "segmentPath", segmentPath); + if (createdAt != null) { + ptr.put("currentCreatedAt", createdAt); + } + if (updatedAt != null) { + ptr.put("updatedAt", updatedAt); + } + + if (writeConditional(prefix, id, ptr, version)) { + return; + } + if (attempt >= MAX_ATTEMPTS) { + logger.debug("Pointer for session {} not advanced (lost concurrency race)", sessionId); + return; + } + } + } + + // ────────────────────────────────────────────────────────────────────────── + // SnapshotReader + // ────────────────────────────────────────────────────────────────────────── + + /** + * {@inheritDoc} + * + *

By {@code snapshotId}: loads the snapshot record and reconstructs its state from the + * checkpoint shards + ordered {@code segmentPath} diffs. By {@code sessionId}: reads the pointer, + * then loads and reconstructs the pointed snapshot. + */ + @Override + public SessionSnapshot getSnapshot(GetSnapshotOptions opts) { + if (opts == null) { + return null; + } + String prefix = prefix(SessionStoreOptions.empty()); + + if (opts.getSnapshotId() != null) { + SnapshotSharding.validateId(opts.getSnapshotId()); + Row row = readRow(prefix, snapId(opts.getSnapshotId())); + return row == null ? null : readSnapshot(prefix, row.doc); + } + if (opts.getSessionId() != null) { + SnapshotSharding.validateId(opts.getSessionId()); + Row pointer = readRow(prefix, ptrId(opts.getSessionId())); + if (pointer == null) { + return null; + } + String leafId = getString(pointer.doc, "currentSnapshotId"); + if (leafId == null) { + return null; + } + Row row = readRow(prefix, snapId(leafId)); + return row == null ? null : readSnapshot(prefix, row.doc); + } + return null; + } + + // ────────────────────────────────────────────────────────────────────────── + // SnapshotSubscriber (polling) + // ────────────────────────────────────────────────────────────────────────── + + /** + * {@inheritDoc} + * + *

The subscription polls {@link #getSnapshot} on a shared daemon scheduler and fires the + * callback whenever the serialized snapshot content changes. The callback also fires immediately + * if the snapshot already exists. + */ + @Override + public AutoCloseable onSnapshotStateChange( + String snapshotId, Consumer> cb, SessionStoreOptions storeOpts) { + SnapshotSharding.validateId(snapshotId); + GetSnapshotOptions get = GetSnapshotOptions.builder().snapshotId(snapshotId).build(); + + final String[] lastContent = {null}; + SessionSnapshot initial = getSnapshot(get); + if (initial != null) { + lastContent[0] = serializeQuietly(initial); + cb.accept(initial); + } + + ScheduledFuture future = + scheduler.scheduleAtFixedRate( + () -> { + try { + SessionSnapshot snap = getSnapshot(get); + if (snap == null) { + return; + } + String content = serializeQuietly(snap); + if (!content.equals(lastContent[0])) { + lastContent[0] = content; + cb.accept(snap); + } + } catch (Exception e) { + // Swallow poll errors — don't kill the scheduler thread. + } + }, + options.getPollIntervalMs(), + options.getPollIntervalMs(), + TimeUnit.MILLISECONDS); + + return () -> future.cancel(false); + } + + // ────────────────────────────────────────────────────────────────────────── + // Document (de)serialization + // ────────────────────────────────────────────────────────────────────────── + + /** Builds the non-state base document for a snapshot (metadata only). */ + private ObjectNode baseDoc(SessionSnapshot snap) { + ObjectNode data = MAPPER.createObjectNode(); + data.put("snapshotId", snap.getSnapshotId()); + data.put("sessionId", snap.getSessionId()); + if (snap.getParentId() != null) { + data.put("parentId", snap.getParentId()); + } + if (snap.getCreatedAt() != null) { + data.put("createdAt", snap.getCreatedAt()); + } + if (snap.getUpdatedAt() != null) { + data.put("updatedAt", snap.getUpdatedAt()); + } + if (snap.getHeartbeatAt() != null) { + data.put("heartbeatAt", snap.getHeartbeatAt()); + } + if (snap.getStatus() != null) { + data.put("status", snap.getStatus().getValue()); + } + if (snap.getFinishReason() != null) { + data.put("finishReason", snap.getFinishReason().getValue()); + } + if (snap.getError() != null) { + data.put("error", writeJson(snap.getError())); + } + return data; + } + + /** Holds the reconstructed parent state and its checkpoint lineage. */ + private static final class ParentInfo { + JsonNode state; + String checkpointId; + int checkpointShardCount; + List segmentPath; + } + + /** Loads a parent snapshot's checkpoint lineage and reconstructs its state. */ + private ParentInfo loadParentInfo(String prefix, ObjectNode doc) { + ParentInfo info = new ParentInfo(); + info.checkpointId = getString(doc, "checkpointId"); + info.checkpointShardCount = getInt(doc, "checkpointShardCount"); + info.segmentPath = getStringList(doc, "segmentPath"); + info.state = + reconstructFullState( + prefix, info.checkpointId, info.checkpointShardCount, info.segmentPath); + return info; + } + + /** Reads and fully reconstructs a snapshot from its metadata document. */ + private SessionSnapshot readSnapshot(String prefix, ObjectNode doc) { + String checkpointId = getString(doc, "checkpointId"); + int checkpointShardCount = getInt(doc, "checkpointShardCount"); + List segmentPath = getStringList(doc, "segmentPath"); + JsonNode state = reconstructFullState(prefix, checkpointId, checkpointShardCount, segmentPath); + return docToSnapshot(doc, state); + } + + /** + * Reconstructs full state: loads the checkpoint shards (concatenate, parse) then applies the + * {@code segmentPath} diffs in order. + */ + private JsonNode reconstructFullState( + String prefix, String checkpointId, int checkpointShardCount, List segmentPath) { + if (checkpointId == null) { + return NullNode.getInstance(); + } + List shardContents = new ArrayList<>(); + for (int i = 0; i < checkpointShardCount; i++) { + Row shard = readRow(prefix, shardId(checkpointId, i)); + shardContents.add(shard != null ? getString(shard.doc, "data") : ""); + } + String checkpointJson = SnapshotSharding.reassembleShards(shardContents); + + List diffs = new ArrayList<>(); + for (String diffId : segmentPath) { + Row diffRow = readRow(prefix, snapId(diffId)); + if (diffRow != null) { + String patch = getString(diffRow.doc, "statePatch"); + if (patch != null) { + diffs.add(patch); + } + } + } + try { + return SnapshotSharding.reconstructState(checkpointJson, diffs); + } catch (Exception e) { + throw new GenkitException("Failed to reconstruct session state: " + e.getMessage(), e); + } + } + + /** Builds a {@link SessionSnapshot} from a metadata document and reconstructed state. */ + @SuppressWarnings("unchecked") + private SessionSnapshot docToSnapshot(ObjectNode doc, JsonNode state) { + SessionSnapshot.Builder builder = SessionSnapshot.builder(); + builder.snapshotId(getString(doc, "snapshotId")); + builder.sessionId(getString(doc, "sessionId")); + builder.parentId(getString(doc, "parentId")); + builder.createdAt(getString(doc, "createdAt")); + builder.updatedAt(getString(doc, "updatedAt")); + builder.heartbeatAt(getString(doc, "heartbeatAt")); + String status = getString(doc, "status"); + if (status != null) { + builder.status(SnapshotStatus.fromValueOrCompleted(status)); + } + String finishReason = getString(doc, "finishReason"); + if (finishReason != null) { + builder.finishReason(AgentFinishReason.fromValue(finishReason)); + } + String errorJson = getString(doc, "error"); + if (errorJson != null) { + try { + builder.error(MAPPER.readValue(errorJson, RuntimeError.class)); + } catch (Exception e) { + throw new GenkitException("Failed to parse snapshot error: " + e.getMessage(), e); + } + } + if (state != null && !state.isNull()) { + try { + builder.state((SessionState) MAPPER.treeToValue(state, SessionState.class)); + } catch (Exception e) { + throw new GenkitException("Failed to parse session state: " + e.getMessage(), e); + } + } + return builder.build(); + } + + /** Serializes session state to a JSON node (null state → JSON null). */ + private JsonNode stateToJson(SessionState state) { + if (state == null) { + return NullNode.getInstance(); + } + return MAPPER.valueToTree(state); + } + + // ────────────────────────────────────────────────────────────────────────── + // Low-level JDBC + node helpers + // ────────────────────────────────────────────────────────────────────────── + + /** A single stored row: its JSON document and optimistic-concurrency version. */ + private static final class Row { + final ObjectNode doc; + final long version; + + Row(ObjectNode doc, long version) { + this.doc = doc; + this.version = version; + } + } + + /** Reads a row by key, or {@code null} when it does not exist. */ + private Row readRow(String prefix, String id) { + String sql = "SELECT doc, version FROM " + table + " WHERE pk = ? AND id = ?"; + try (Connection conn = dataSource.getConnection(); + PreparedStatement pstmt = conn.prepareStatement(sql)) { + pstmt.setString(1, prefix); + pstmt.setString(2, id); + try (ResultSet rs = pstmt.executeQuery()) { + if (!rs.next()) { + return null; + } + String docJson = rs.getString(1); + long version = rs.getLong(2); + return new Row((ObjectNode) MAPPER.readTree(docJson), version); + } + } catch (Exception e) { + throw new GenkitException("Failed to read session row " + id + ": " + e.getMessage(), e); + } + } + + /** Idempotently upserts a row (used for shard records), bumping the version. */ + private void upsertRow(String prefix, String id, ObjectNode doc) { + String sql = + "INSERT INTO " + + table + + " (pk, id, doc, version) VALUES (?, ?, ?::jsonb, 1) " + + "ON CONFLICT (pk, id) DO UPDATE SET doc = EXCLUDED.doc, version = " + + table + + ".version + 1"; + try (Connection conn = dataSource.getConnection(); + PreparedStatement pstmt = conn.prepareStatement(sql)) { + pstmt.setString(1, prefix); + pstmt.setString(2, id); + pstmt.setString(3, writeJson(doc)); + pstmt.executeUpdate(); + } catch (SQLException e) { + throw new GenkitException("Failed to upsert session row " + id + ": " + e.getMessage(), e); + } + } + + /** + * Writes a row with optimistic concurrency: inserts when {@code expectedVersion} is {@code null}, + * otherwise updates only when the stored version still matches. Returns {@code false} when a + * concurrent writer won the race (the caller re-reads and retries the pure mutator). + */ + private boolean writeConditional(String prefix, String id, ObjectNode doc, Long expectedVersion) { + String docJson = writeJson(doc); + if (expectedVersion == null) { + String sql = + "INSERT INTO " + + table + + " (pk, id, doc, version) VALUES (?, ?, ?::jsonb, 1) ON CONFLICT (pk, id) DO NOTHING"; + try (Connection conn = dataSource.getConnection(); + PreparedStatement pstmt = conn.prepareStatement(sql)) { + pstmt.setString(1, prefix); + pstmt.setString(2, id); + pstmt.setString(3, docJson); + return pstmt.executeUpdate() == 1; + } catch (SQLException e) { + throw new GenkitException("Failed to insert session row " + id + ": " + e.getMessage(), e); + } + } + String sql = + "UPDATE " + + table + + " SET doc = ?::jsonb, version = version + 1 WHERE pk = ? AND id = ? AND version = ?"; + try (Connection conn = dataSource.getConnection(); + PreparedStatement pstmt = conn.prepareStatement(sql)) { + pstmt.setString(1, docJson); + pstmt.setString(2, prefix); + pstmt.setString(3, id); + pstmt.setLong(4, expectedVersion); + return pstmt.executeUpdate() == 1; + } catch (SQLException e) { + throw new GenkitException("Failed to update session row " + id + ": " + e.getMessage(), e); + } + } + + private static void putStringArray(ObjectNode node, String name, List values) { + ArrayNode arr = node.putArray(name); + for (String v : values) { + arr.add(v); + } + } + + private static String getString(ObjectNode node, String name) { + JsonNode v = node.get(name); + return (v == null || v.isNull()) ? null : v.asText(); + } + + private static int getInt(ObjectNode node, String name) { + JsonNode v = node.get(name); + return (v == null || v.isNull()) ? 0 : v.asInt(); + } + + private static List getStringList(ObjectNode node, String name) { + List out = new ArrayList<>(); + JsonNode v = node.get(name); + if (v != null && v.isArray()) { + for (JsonNode e : v) { + out.add(e.asText()); + } + } + return out; + } + + private static String writeJson(Object value) { + try { + return MAPPER.writeValueAsString(value); + } catch (Exception e) { + throw new GenkitException("Failed to serialize value: " + e.getMessage(), e); + } + } + + private static int jsonBytes(Object value) { + return writeJson(value).getBytes(StandardCharsets.UTF_8).length; + } + + private static String serializeQuietly(SessionSnapshot snap) { + try { + return MAPPER.writeValueAsString(MAPPER.valueToTree(snap)); + } catch (Exception e) { + return ""; + } + } + + /** Quotes a SQL identifier, doubling embedded double quotes. */ + private static String quoteIdentifier(String identifier) { + return "\"" + identifier.replace("\"", "\"\"") + "\""; + } +} diff --git a/plugins/postgresql/src/main/java/com/google/genkit/plugins/postgresql/session/PostgresSessionStoreOptions.java b/plugins/postgresql/src/main/java/com/google/genkit/plugins/postgresql/session/PostgresSessionStoreOptions.java new file mode 100644 index 000000000..c79cafaa5 --- /dev/null +++ b/plugins/postgresql/src/main/java/com/google/genkit/plugins/postgresql/session/PostgresSessionStoreOptions.java @@ -0,0 +1,243 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed 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. + * + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.google.genkit.plugins.postgresql.session; + +import com.google.genkit.ai.agent.SessionStoreOptions; +import java.util.function.Function; + +/** + * Configuration for {@link PostgresSessionStore}. + * + *

The store persists all records in a single PostgreSQL table (default {@value #DEFAULT_TABLE}) + * keyed by {@code (pk, id)}. The {@code pk} column holds the per-tenant prefix (default {@code + * "global"}); the {@code id} column discriminates snapshot, shard, and pointer records. Each row + * carries a JSONB {@code doc} payload and a monotonically increasing {@code version} used for + * optimistic concurrency. + * + *

The default {@link #getShardSize()} is {@value #DEFAULT_SHARD_SIZE} bytes; because the store + * forces a checkpoint whenever a diff would exceed the shard size, diff rows stay bounded too. + */ +public final class PostgresSessionStoreOptions { + + /** Default table name. */ + public static final String DEFAULT_TABLE = "genkit_sessions"; + + /** Default number of turns between full checkpoints. */ + public static final int DEFAULT_CHECKPOINT_INTERVAL = 25; + + /** Default shard size in bytes for checkpoint state (1 MiB). */ + public static final int DEFAULT_SHARD_SIZE = 1024 * 1024; + + /** Default subscription poll interval in milliseconds. */ + public static final long DEFAULT_POLL_INTERVAL_MS = 2000L; + + private final String tableName; + private final int checkpointInterval; + private final int shardSize; + private final Function snapshotPathPrefix; + private final boolean createTableIfNotExists; + private final long pollIntervalMs; + + private PostgresSessionStoreOptions(Builder builder) { + this.tableName = builder.tableName; + this.checkpointInterval = builder.checkpointInterval; + this.shardSize = builder.shardSize; + this.snapshotPathPrefix = builder.snapshotPathPrefix; + this.createTableIfNotExists = builder.createTableIfNotExists; + this.pollIntervalMs = builder.pollIntervalMs; + } + + /** + * Returns the PostgreSQL table name (default {@value #DEFAULT_TABLE}). + * + * @return the table name + */ + public String getTableName() { + return tableName; + } + + /** + * Returns the number of turns between full checkpoints (default {@value + * #DEFAULT_CHECKPOINT_INTERVAL}). + * + * @return the checkpoint interval + */ + public int getCheckpointInterval() { + return checkpointInterval; + } + + /** + * Returns the shard size in bytes for checkpoint state (default {@value #DEFAULT_SHARD_SIZE}). + * + * @return the shard size in bytes + */ + public int getShardSize() { + return shardSize; + } + + /** + * Returns the function that derives the per-tenant prefix from the per-request store options + * (default {@code o -> "global"}). + * + * @return the prefix function + */ + public Function getSnapshotPathPrefix() { + return snapshotPathPrefix; + } + + /** + * Returns whether the store should create the table on first use if it does not exist (default + * {@code false}). + * + * @return {@code true} if the table should be created when missing + */ + public boolean isCreateTableIfNotExists() { + return createTableIfNotExists; + } + + /** + * Returns the subscription poll interval in milliseconds (default {@value + * #DEFAULT_POLL_INTERVAL_MS}). + * + * @return the poll interval in milliseconds + */ + public long getPollIntervalMs() { + return pollIntervalMs; + } + + /** + * Returns default options. + * + * @return a {@code PostgresSessionStoreOptions} with all defaults + */ + public static PostgresSessionStoreOptions defaults() { + return builder().build(); + } + + /** + * Creates a new builder. + * + * @return a new builder + */ + public static Builder builder() { + return new Builder(); + } + + /** Builder for {@link PostgresSessionStoreOptions}. */ + public static final class Builder { + private String tableName = DEFAULT_TABLE; + private int checkpointInterval = DEFAULT_CHECKPOINT_INTERVAL; + private int shardSize = DEFAULT_SHARD_SIZE; + private Function snapshotPathPrefix = o -> "global"; + private boolean createTableIfNotExists = false; + private long pollIntervalMs = DEFAULT_POLL_INTERVAL_MS; + + private Builder() {} + + /** + * Sets the PostgreSQL table name. + * + * @param tableName the table name + * @return this builder + */ + public Builder tableName(String tableName) { + this.tableName = tableName; + return this; + } + + /** + * Sets the number of turns between full checkpoints. + * + * @param checkpointInterval the checkpoint interval (must be {@code >= 1}) + * @return this builder + */ + public Builder checkpointInterval(int checkpointInterval) { + this.checkpointInterval = checkpointInterval; + return this; + } + + /** + * Sets the shard size in bytes for checkpoint state. + * + * @param shardSize the shard size in bytes (must be {@code >= 1}) + * @return this builder + */ + public Builder shardSize(int shardSize) { + this.shardSize = shardSize; + return this; + } + + /** + * Sets the function that derives the per-tenant prefix. + * + * @param snapshotPathPrefix the prefix function + * @return this builder + */ + public Builder snapshotPathPrefix(Function snapshotPathPrefix) { + this.snapshotPathPrefix = snapshotPathPrefix; + return this; + } + + /** + * Sets whether to create the table on first use if it does not exist. + * + * @param createTableIfNotExists whether to create the table when missing + * @return this builder + */ + public Builder createTableIfNotExists(boolean createTableIfNotExists) { + this.createTableIfNotExists = createTableIfNotExists; + return this; + } + + /** + * Sets the subscription poll interval in milliseconds. + * + * @param pollIntervalMs the poll interval (must be {@code >= 1}) + * @return this builder + */ + public Builder pollIntervalMs(long pollIntervalMs) { + this.pollIntervalMs = pollIntervalMs; + return this; + } + + /** + * Builds a new {@code PostgresSessionStoreOptions}. + * + * @return a new options instance + */ + public PostgresSessionStoreOptions build() { + if (tableName == null || tableName.isBlank()) { + throw new IllegalArgumentException("tableName must be non-empty"); + } + if (checkpointInterval < 1) { + throw new IllegalArgumentException("checkpointInterval must be >= 1"); + } + if (shardSize < 1) { + throw new IllegalArgumentException("shardSize must be >= 1"); + } + if (snapshotPathPrefix == null) { + throw new IllegalArgumentException("snapshotPathPrefix must be non-null"); + } + if (pollIntervalMs < 1) { + throw new IllegalArgumentException("pollIntervalMs must be >= 1"); + } + return new PostgresSessionStoreOptions(this); + } + } +} diff --git a/plugins/postgresql/src/main/java/com/google/genkit/plugins/postgresql/session/package-info.java b/plugins/postgresql/src/main/java/com/google/genkit/plugins/postgresql/session/package-info.java new file mode 100644 index 000000000..833aed206 --- /dev/null +++ b/plugins/postgresql/src/main/java/com/google/genkit/plugins/postgresql/session/package-info.java @@ -0,0 +1,27 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed 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. + * + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * PostgreSQL-backed agent session persistence. + * + *

{@link com.google.genkit.plugins.postgresql.session.PostgresSessionStore} implements the + * Genkit {@code SessionStore} contract using the sharded checkpoint + RFC-6902 diff + pointer + * layout shared with the Firestore, DynamoDB, and Cosmos DB backends. Construct it directly from a + * {@code javax.sql.DataSource} and pass it to an agent via {@code AgentConfig.store(...)}. + */ +package com.google.genkit.plugins.postgresql.session; diff --git a/plugins/postgresql/src/test/java/com/google/genkit/plugins/postgresql/session/PostgresSessionStoreOptionsTest.java b/plugins/postgresql/src/test/java/com/google/genkit/plugins/postgresql/session/PostgresSessionStoreOptionsTest.java new file mode 100644 index 000000000..e365cac5b --- /dev/null +++ b/plugins/postgresql/src/test/java/com/google/genkit/plugins/postgresql/session/PostgresSessionStoreOptionsTest.java @@ -0,0 +1,76 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed 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. + * + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.google.genkit.plugins.postgresql.session; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import com.google.genkit.ai.agent.SessionStoreOptions; +import org.junit.jupiter.api.Test; + +/** Unit tests for {@link PostgresSessionStoreOptions}. */ +class PostgresSessionStoreOptionsTest { + + @Test + void defaultsAreSane() { + PostgresSessionStoreOptions o = PostgresSessionStoreOptions.defaults(); + assertEquals("genkit_sessions", o.getTableName()); + assertEquals(25, o.getCheckpointInterval()); + assertEquals(1024 * 1024, o.getShardSize()); + assertEquals("global", o.getSnapshotPathPrefix().apply(SessionStoreOptions.empty())); + assertFalse(o.isCreateTableIfNotExists()); + assertEquals(2000L, o.getPollIntervalMs()); + } + + @Test + void customBuilder() { + PostgresSessionStoreOptions o = + PostgresSessionStoreOptions.builder() + .tableName("my_sessions") + .checkpointInterval(10) + .shardSize(4096) + .snapshotPathPrefix(so -> "tenant-1") + .createTableIfNotExists(true) + .pollIntervalMs(500) + .build(); + assertEquals("my_sessions", o.getTableName()); + assertEquals(10, o.getCheckpointInterval()); + assertEquals(4096, o.getShardSize()); + assertEquals("tenant-1", o.getSnapshotPathPrefix().apply(SessionStoreOptions.empty())); + assertEquals(true, o.isCreateTableIfNotExists()); + assertEquals(500L, o.getPollIntervalMs()); + } + + @Test + void builderValidates() { + assertThrows( + IllegalArgumentException.class, + () -> PostgresSessionStoreOptions.builder().tableName("").build()); + assertThrows( + IllegalArgumentException.class, + () -> PostgresSessionStoreOptions.builder().checkpointInterval(0).build()); + assertThrows( + IllegalArgumentException.class, + () -> PostgresSessionStoreOptions.builder().shardSize(0).build()); + assertThrows( + IllegalArgumentException.class, + () -> PostgresSessionStoreOptions.builder().pollIntervalMs(0).build()); + } +} diff --git a/plugins/postgresql/src/test/java/com/google/genkit/plugins/postgresql/session/PostgresSessionStoreTest.java b/plugins/postgresql/src/test/java/com/google/genkit/plugins/postgresql/session/PostgresSessionStoreTest.java new file mode 100644 index 000000000..53176240b --- /dev/null +++ b/plugins/postgresql/src/test/java/com/google/genkit/plugins/postgresql/session/PostgresSessionStoreTest.java @@ -0,0 +1,212 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed 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. + * + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.google.genkit.plugins.postgresql.session; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assumptions.assumeTrue; + +import com.google.genkit.ai.Message; +import com.google.genkit.ai.agent.GetSnapshotOptions; +import com.google.genkit.ai.agent.SessionSnapshot; +import com.google.genkit.ai.agent.SessionState; +import com.google.genkit.ai.agent.SessionStoreOptions; +import com.google.genkit.ai.agent.SnapshotStatus; +import com.google.genkit.core.GenkitException; +import java.sql.Connection; +import java.sql.Statement; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.postgresql.ds.PGSimpleDataSource; + +/** + * Tests for {@link PostgresSessionStore}. + * + *

Integration tests are gated on the {@code POSTGRES_URL} environment variable (e.g. a local + * Postgres started with Docker). {@code POSTGRES_USER} and {@code POSTGRES_PASSWORD} default to + * {@code postgres}. When {@code POSTGRES_URL} is unset the tests are skipped via {@link + * org.junit.jupiter.api.Assumptions}. + */ +class PostgresSessionStoreTest { + + private static final String URL = System.getenv("POSTGRES_URL"); + private static final String USER = envOrDefault("POSTGRES_USER", "postgres"); + private static final String PASSWORD = envOrDefault("POSTGRES_PASSWORD", "postgres"); + + private PGSimpleDataSource dataSource; + private String table; + private PostgresSessionStore> store; + + private static String envOrDefault(String name, String fallback) { + String v = System.getenv(name); + return (v != null && !v.isBlank()) ? v : fallback; + } + + private static boolean configured() { + return URL != null && !URL.isEmpty(); + } + + @BeforeEach + void setUp() { + if (!configured()) { + return; // integration tests skip via assumeTrue + } + dataSource = new PGSimpleDataSource(); + dataSource.setUrl(URL); + dataSource.setUser(USER); + dataSource.setPassword(PASSWORD); + table = "genkit_sessions_test_" + UUID.randomUUID().toString().replace("-", ""); + store = + new PostgresSessionStore<>( + dataSource, + PostgresSessionStoreOptions.builder() + .tableName(table) + .checkpointInterval(3) + .createTableIfNotExists(true) + .build()); + } + + @AfterEach + void tearDown() throws Exception { + if (dataSource != null && table != null) { + try (Connection conn = dataSource.getConnection(); + Statement stmt = conn.createStatement()) { + stmt.execute("DROP TABLE IF EXISTS \"" + table + "\""); + } + } + } + + private static SessionSnapshot> snapshotWithState( + String sessionId, String parentId, Map custom) { + SessionState> state = + SessionState.>builder() + .sessionId(sessionId) + .messages(List.of(Message.user("hello"))) + .custom(custom) + .build(); + return SessionSnapshot.>builder() + .sessionId(sessionId) + .parentId(parentId) + .status(SnapshotStatus.COMPLETED) + .state(state) + .build(); + } + + @Test + void saveThenGetBySnapshotIdRoundTrips() { + assumeTrue(configured()); + String sessionId = "s-" + UUID.randomUUID(); + Map custom = new HashMap<>(); + custom.put("count", 1); + + String id = + store.saveSnapshot( + null, e -> snapshotWithState(sessionId, null, custom), SessionStoreOptions.empty()); + assertNotNull(id); + + SessionSnapshot> got = + store.getSnapshot(GetSnapshotOptions.builder().snapshotId(id).build()); + assertNotNull(got); + assertEquals(sessionId, got.getSessionId()); + assertEquals(SnapshotStatus.COMPLETED, got.getStatus()); + assertEquals(1, got.getState().getMessages().size()); + assertEquals(1, ((Number) got.getState().getCustom().get("count")).intValue()); + } + + @Test + void getBySessionIdReturnsLeaf() { + assumeTrue(configured()); + String sessionId = "s-" + UUID.randomUUID(); + + Map c1 = new HashMap<>(); + c1.put("count", 1); + String id1 = + store.saveSnapshot( + null, e -> snapshotWithState(sessionId, null, c1), SessionStoreOptions.empty()); + Map c2 = new HashMap<>(); + c2.put("count", 2); + String id2 = + store.saveSnapshot( + null, e -> snapshotWithState(sessionId, id1, c2), SessionStoreOptions.empty()); + + SessionSnapshot> latest = + store.getSnapshot(GetSnapshotOptions.builder().sessionId(sessionId).build()); + assertNotNull(latest); + assertEquals(id2, latest.getSnapshotId()); + assertEquals(2, ((Number) latest.getState().getCustom().get("count")).intValue()); + } + + @Test + void diffThenCheckpointReconstructs() { + assumeTrue(configured()); + // checkpointInterval is 3; save 5 turns across checkpoint boundaries and confirm the leaf + // reconstructs correctly (checkpoint shards + segment-path diffs). + String sessionId = "s-" + UUID.randomUUID(); + String parent = null; + String lastId = null; + for (int i = 1; i <= 5; i++) { + Map c = new HashMap<>(); + c.put("count", i); + final String p = parent; + lastId = + store.saveSnapshot( + null, e -> snapshotWithState(sessionId, p, c), SessionStoreOptions.empty()); + parent = lastId; + } + SessionSnapshot> got = + store.getSnapshot(GetSnapshotOptions.builder().snapshotId(lastId).build()); + assertNotNull(got); + assertEquals(5, ((Number) got.getState().getCustom().get("count")).intValue()); + } + + @Test + void rejectsEmptySessionId() { + assumeTrue(configured()); + GenkitException ex = + assertThrows( + GenkitException.class, + () -> + store.saveSnapshot( + null, + e -> snapshotWithState("", null, new HashMap<>()), + SessionStoreOptions.empty())); + assertEquals("INVALID_ARGUMENT", ex.getErrorCode()); + } + + @Test + void mutatorNullIsNoOp() { + assumeTrue(configured()); + assertNull(store.saveSnapshot(null, e -> null, SessionStoreOptions.empty())); + } + + @Test + void getUnknownSessionReturnsNull() { + assumeTrue(configured()); + assertNull( + store.getSnapshot( + GetSnapshotOptions.builder().sessionId("s-" + UUID.randomUUID()).build())); + } +} diff --git a/plugins/qdrant/pom.xml b/plugins/qdrant/pom.xml new file mode 100644 index 000000000..c7fd86ca4 --- /dev/null +++ b/plugins/qdrant/pom.xml @@ -0,0 +1,78 @@ + + + + 4.0.0 + + + com.google.genkit + genkit-parent + 1.0.0-SNAPSHOT + ../../pom.xml + + + genkit-plugin-qdrant + jar + Genkit Qdrant Plugin + Qdrant vector database integration for Genkit - indexer and retriever for RAG workflows + + + false + + + + + + com.google.genkit + genkit-core + ${project.version} + + + com.google.genkit + genkit-ai + ${project.version} + + + + + com.fasterxml.jackson.core + jackson-databind + + + + + org.slf4j + slf4j-api + + + + + org.junit.jupiter + junit-jupiter + test + + + org.mockito + mockito-core + test + + + diff --git a/plugins/qdrant/src/main/java/com/google/genkit/plugins/qdrant/QdrantCollectionConfig.java b/plugins/qdrant/src/main/java/com/google/genkit/plugins/qdrant/QdrantCollectionConfig.java new file mode 100644 index 000000000..108e2decc --- /dev/null +++ b/plugins/qdrant/src/main/java/com/google/genkit/plugins/qdrant/QdrantCollectionConfig.java @@ -0,0 +1,258 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed 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. + * + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.google.genkit.plugins.qdrant; + +import java.util.HashMap; +import java.util.Map; + +/** + * Configuration for a single Qdrant collection managed by {@link QdrantPlugin}. + * + *

Each config registers a retriever and indexer named {@code qdrant/}. + */ +public final class QdrantCollectionConfig { + + /** Distance function used by the Qdrant collection. */ + public enum Distance { + COSINE("Cosine"), + EUCLIDEAN("Euclid"), + DOT_PRODUCT("Dot"); + + private final String value; + + Distance(String value) { + this.value = value; + } + + /** + * Returns the Qdrant distance name. + * + * @return the distance name + */ + public String getValue() { + return value; + } + } + + private final String collectionName; + private final String embedderName; + private final int dimension; + private final Distance distance; + private final String textPayloadKey; + private final boolean createCollectionIfNotExists; + private final Map additionalMetadata; + + private QdrantCollectionConfig(Builder builder) { + this.collectionName = builder.collectionName; + this.embedderName = builder.embedderName; + this.dimension = builder.dimension; + this.distance = builder.distance; + this.textPayloadKey = builder.textPayloadKey; + this.createCollectionIfNotExists = builder.createCollectionIfNotExists; + this.additionalMetadata = new HashMap<>(builder.additionalMetadata); + } + + /** + * Returns the Qdrant collection name. + * + * @return the collection name + */ + public String getCollectionName() { + return collectionName; + } + + /** + * Returns the name of the embedder used to vectorize documents and queries. + * + * @return the embedder name + */ + public String getEmbedderName() { + return embedderName; + } + + /** + * Returns the embedding dimension (default {@code 768}). + * + * @return the embedding dimension + */ + public int getDimension() { + return dimension; + } + + /** + * Returns the distance function (default {@link Distance#COSINE}). + * + * @return the distance function + */ + public Distance getDistance() { + return distance; + } + + /** + * Returns the payload key that stores the document text (default {@code text}). + * + * @return the text payload key + */ + public String getTextPayloadKey() { + return textPayloadKey; + } + + /** + * Returns whether to create the collection on first use if it does not exist (default {@code + * true}). + * + * @return {@code true} if the collection should be created when missing + */ + public boolean isCreateCollectionIfNotExists() { + return createCollectionIfNotExists; + } + + /** + * Returns additional metadata merged into every indexed document's payload. + * + * @return the additional metadata + */ + public Map getAdditionalMetadata() { + return additionalMetadata; + } + + /** + * Creates a new builder. + * + * @return a new builder + */ + public static Builder builder() { + return new Builder(); + } + + /** Builder for {@link QdrantCollectionConfig}. */ + public static final class Builder { + private String collectionName; + private String embedderName; + private int dimension = 768; + private Distance distance = Distance.COSINE; + private String textPayloadKey = "text"; + private boolean createCollectionIfNotExists = true; + private final Map additionalMetadata = new HashMap<>(); + + private Builder() {} + + /** + * Sets the collection name. + * + * @param collectionName the collection name + * @return this builder + */ + public Builder collectionName(String collectionName) { + this.collectionName = collectionName; + return this; + } + + /** + * Sets the embedder name. + * + * @param embedderName the embedder name + * @return this builder + */ + public Builder embedderName(String embedderName) { + this.embedderName = embedderName; + return this; + } + + /** + * Sets the embedding dimension. + * + * @param dimension the embedding dimension (must be {@code >= 1}) + * @return this builder + */ + public Builder dimension(int dimension) { + this.dimension = dimension; + return this; + } + + /** + * Sets the distance function. + * + * @param distance the distance function + * @return this builder + */ + public Builder distance(Distance distance) { + this.distance = distance; + return this; + } + + /** + * Sets the payload key that stores the document text. + * + * @param textPayloadKey the text payload key + * @return this builder + */ + public Builder textPayloadKey(String textPayloadKey) { + this.textPayloadKey = textPayloadKey; + return this; + } + + /** + * Sets whether to create the collection on first use if it does not exist. + * + * @param createCollectionIfNotExists whether to create the collection when missing + * @return this builder + */ + public Builder createCollectionIfNotExists(boolean createCollectionIfNotExists) { + this.createCollectionIfNotExists = createCollectionIfNotExists; + return this; + } + + /** + * Adds a metadata entry merged into every indexed document's payload. + * + * @param key the metadata key + * @param value the metadata value + * @return this builder + */ + public Builder addAdditionalMetadata(String key, Object value) { + this.additionalMetadata.put(key, value); + return this; + } + + /** + * Builds a new {@code QdrantCollectionConfig}. + * + * @return a new config instance + */ + public QdrantCollectionConfig build() { + if (collectionName == null || collectionName.isBlank()) { + throw new IllegalArgumentException("collectionName must be non-empty"); + } + if (embedderName == null || embedderName.isBlank()) { + throw new IllegalArgumentException("embedderName must be non-empty"); + } + if (dimension < 1) { + throw new IllegalArgumentException("dimension must be >= 1"); + } + if (distance == null) { + throw new IllegalArgumentException("distance must be non-null"); + } + if (textPayloadKey == null || textPayloadKey.isBlank()) { + throw new IllegalArgumentException("textPayloadKey must be non-empty"); + } + return new QdrantCollectionConfig(this); + } + } +} diff --git a/plugins/qdrant/src/main/java/com/google/genkit/plugins/qdrant/QdrantPlugin.java b/plugins/qdrant/src/main/java/com/google/genkit/plugins/qdrant/QdrantPlugin.java new file mode 100644 index 000000000..33268895d --- /dev/null +++ b/plugins/qdrant/src/main/java/com/google/genkit/plugins/qdrant/QdrantPlugin.java @@ -0,0 +1,176 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed 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. + * + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.google.genkit.plugins.qdrant; + +import com.google.genkit.ai.Embedder; +import com.google.genkit.core.Action; +import com.google.genkit.core.ActionType; +import com.google.genkit.core.Plugin; +import com.google.genkit.core.Registry; +import java.util.ArrayList; +import java.util.List; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Qdrant vector database plugin for Genkit. + * + *

Registers a retriever and indexer named {@code qdrant/} for each configured + * collection, talking to a Qdrant server over its REST API. + * + *

Example usage: + * + *

{@code
+ * Genkit genkit = Genkit.builder()
+ *     .plugin(GoogleGenAIPlugin.create(apiKey))
+ *     .plugin(
+ *         QdrantPlugin.builder()
+ *             .url("http://localhost:6333")
+ *             .addCollection(
+ *                 QdrantCollectionConfig.builder()
+ *                     .collectionName("films")
+ *                     .embedderName("googleai/gemini-embedding-001")
+ *                     .dimension(768)
+ *                     .build())
+ *             .build())
+ *     .build();
+ * }
+ */ +public final class QdrantPlugin implements Plugin { + + /** The plugin name; used as the {@code qdrant/...} action prefix. */ + public static final String PLUGIN_NAME = "qdrant"; + + /** Default Qdrant server URL. */ + public static final String DEFAULT_URL = "http://localhost:6333"; + + private static final Logger logger = LoggerFactory.getLogger(QdrantPlugin.class); + + private final String url; + private final String apiKey; + private final List collectionConfigs; + + private QdrantPlugin(Builder builder) { + this.url = builder.url; + this.apiKey = builder.apiKey; + this.collectionConfigs = new ArrayList<>(builder.collectionConfigs); + } + + /** + * Creates a new builder. + * + * @return a new builder + */ + public static Builder builder() { + return new Builder(); + } + + @Override + public String getName() { + return PLUGIN_NAME; + } + + @Override + public List> init() { + throw new IllegalStateException( + "QdrantPlugin requires a Registry to resolve embedders. Use init(registry) instead."); + } + + @Override + public List> init(Registry registry) { + List> actions = new ArrayList<>(); + for (QdrantCollectionConfig config : collectionConfigs) { + String embedderKey = ActionType.EMBEDDER.keyFromName(config.getEmbedderName()); + Action embedderAction = registry.lookupAction(embedderKey); + if (embedderAction == null) { + throw new IllegalStateException( + "Embedder not found: " + + config.getEmbedderName() + + ". Make sure the embedder plugin is registered before QdrantPlugin."); + } + if (!(embedderAction instanceof Embedder embedder)) { + throw new IllegalStateException( + "Action " + config.getEmbedderName() + " is not an Embedder"); + } + + QdrantVectorStore store = new QdrantVectorStore(url, apiKey, config, embedder); + actions.add(store.createRetriever()); + actions.add(store.createIndexer()); + logger.info("Registered Qdrant vector store: {}/{}", PLUGIN_NAME, config.getCollectionName()); + } + return actions; + } + + /** Builder for {@link QdrantPlugin}. */ + public static final class Builder { + private String url = DEFAULT_URL; + private String apiKey; + private final List collectionConfigs = new ArrayList<>(); + + private Builder() {} + + /** + * Sets the Qdrant server URL (default {@value #DEFAULT_URL}). + * + * @param url the server URL + * @return this builder + */ + public Builder url(String url) { + this.url = url; + return this; + } + + /** + * Sets the Qdrant API key (optional; required for Qdrant Cloud). + * + * @param apiKey the API key + * @return this builder + */ + public Builder apiKey(String apiKey) { + this.apiKey = apiKey; + return this; + } + + /** + * Adds a collection configuration. + * + * @param config the collection configuration + * @return this builder + */ + public Builder addCollection(QdrantCollectionConfig config) { + this.collectionConfigs.add(config); + return this; + } + + /** + * Builds the plugin. + * + * @return a new {@code QdrantPlugin} + */ + public QdrantPlugin build() { + if (url == null || url.isBlank()) { + throw new IllegalStateException("url is required"); + } + if (collectionConfigs.isEmpty()) { + throw new IllegalStateException("At least one collection configuration is required"); + } + return new QdrantPlugin(this); + } + } +} diff --git a/plugins/qdrant/src/main/java/com/google/genkit/plugins/qdrant/QdrantVectorStore.java b/plugins/qdrant/src/main/java/com/google/genkit/plugins/qdrant/QdrantVectorStore.java new file mode 100644 index 000000000..6e7dfc73a --- /dev/null +++ b/plugins/qdrant/src/main/java/com/google/genkit/plugins/qdrant/QdrantVectorStore.java @@ -0,0 +1,333 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed 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. + * + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.google.genkit.plugins.qdrant; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.google.genkit.ai.Document; +import com.google.genkit.ai.EmbedRequest; +import com.google.genkit.ai.EmbedResponse; +import com.google.genkit.ai.Embedder; +import com.google.genkit.ai.Indexer; +import com.google.genkit.ai.IndexerRequest; +import com.google.genkit.ai.IndexerResponse; +import com.google.genkit.ai.Retriever; +import com.google.genkit.ai.RetrieverRequest; +import com.google.genkit.ai.RetrieverResponse; +import com.google.genkit.core.ActionContext; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Qdrant vector store backed by the Qdrant REST API. + * + *

Indexes documents (embedding + payload holding the text and metadata) into a Qdrant collection + * and retrieves the nearest neighbors of a query embedding. + */ +public final class QdrantVectorStore { + + private static final Logger logger = LoggerFactory.getLogger(QdrantVectorStore.class); + private static final ObjectMapper MAPPER = new ObjectMapper(); + + private final HttpClient http = HttpClient.newHttpClient(); + private final String baseUrl; + private final String apiKey; + private final QdrantCollectionConfig config; + private final Embedder embedder; + + private volatile boolean initialized = false; + + /** + * Creates a new store. + * + * @param baseUrl the Qdrant server base URL (e.g. {@code http://localhost:6333}) + * @param apiKey the Qdrant API key, or {@code null} when the server requires none + * @param config the collection configuration + * @param embedder the embedder used to vectorize documents and queries + */ + public QdrantVectorStore( + String baseUrl, String apiKey, QdrantCollectionConfig config, Embedder embedder) { + this.baseUrl = baseUrl.endsWith("/") ? baseUrl.substring(0, baseUrl.length() - 1) : baseUrl; + this.apiKey = apiKey; + this.config = config; + this.embedder = embedder; + } + + /** Creates the retriever action registered by the plugin. */ + Retriever createRetriever() { + String name = QdrantPlugin.PLUGIN_NAME + "/" + config.getCollectionName(); + return Retriever.builder().name(name).handler(this::retrieve).build(); + } + + /** Creates the indexer action registered by the plugin. */ + Indexer createIndexer() { + String name = QdrantPlugin.PLUGIN_NAME + "/" + config.getCollectionName(); + return Indexer.builder().name(name).handler(this::index).build(); + } + + private synchronized void ensureInitialized() { + if (initialized) { + return; + } + if (config.isCreateCollectionIfNotExists() && !collectionExists()) { + ObjectNode body = MAPPER.createObjectNode(); + ObjectNode vectors = body.putObject("vectors"); + vectors.put("size", resolveDimension()); + vectors.put("distance", config.getDistance().getValue()); + send("PUT", "/collections/" + config.getCollectionName(), body); + logger.info("Created Qdrant collection {}", config.getCollectionName()); + } + initialized = true; + } + + /** + * Resolves the vector dimension by probing the embedder, falling back to the configured dimension + * if the probe fails. This keeps the created collection in sync with whatever embedding model is + * wired in. + */ + private int resolveDimension() { + try { + return generateEmbedding(null, "genkit dimension probe").size(); + } catch (RuntimeException e) { + logger.debug( + "Embedding probe failed; using configured dimension {}: {}", + config.getDimension(), + e.getMessage()); + return config.getDimension(); + } + } + + private boolean collectionExists() { + try { + send("GET", "/collections/" + config.getCollectionName(), null); + return true; + } catch (NotFoundException e) { + return false; + } + } + + /** + * Retrieves documents similar to the query. + * + * @param context the action context + * @param request the retriever request + * @return the retriever response with matching documents + */ + public RetrieverResponse retrieve(ActionContext context, RetrieverRequest request) { + ensureInitialized(); + Document queryDoc = request.getQuery(); + if (queryDoc == null || queryDoc.text() == null || queryDoc.text().isBlank()) { + throw new RuntimeException("Query document has no text content"); + } + int topK = + request.getOptions() != null && request.getOptions().getK() != null + ? request.getOptions().getK() + : 10; + List queryEmbedding = generateEmbedding(context, queryDoc.text()); + + ObjectNode body = MAPPER.createObjectNode(); + body.set("vector", floatsToArray(queryEmbedding)); + body.put("limit", topK); + body.put("with_payload", true); + + JsonNode resp = + send("POST", "/collections/" + config.getCollectionName() + "/points/search", body); + List documents = new ArrayList<>(); + JsonNode result = resp.get("result"); + if (result != null && result.isArray()) { + for (JsonNode point : result) { + documents.add(toDocument(point)); + } + } + logger.debug( + "Retrieved {} documents from collection {}", documents.size(), config.getCollectionName()); + return new RetrieverResponse(documents); + } + + /** + * Indexes documents into the collection, generating an embedding for each. + * + * @param context the action context + * @param request the indexer request + * @return the indexer response + */ + public IndexerResponse index(ActionContext context, IndexerRequest request) { + ensureInitialized(); + List documents = request.getDocuments(); + if (documents == null || documents.isEmpty()) { + logger.warn("No documents to index"); + return new IndexerResponse(); + } + + ObjectNode body = MAPPER.createObjectNode(); + ArrayNode points = body.putArray("points"); + for (Document doc : documents) { + String content = doc.text() != null ? doc.text() : ""; + List embedding = generateEmbedding(context, content); + + ObjectNode point = points.addObject(); + point.put("id", getOrGenerateId(doc)); + point.set("vector", floatsToArray(embedding)); + + ObjectNode payload = point.putObject("payload"); + payload.put(config.getTextPayloadKey(), content); + if (doc.getMetadata() != null) { + for (Map.Entry entry : doc.getMetadata().entrySet()) { + if (!"id".equals(entry.getKey())) { + payload.set(entry.getKey(), MAPPER.valueToTree(entry.getValue())); + } + } + } + for (Map.Entry entry : config.getAdditionalMetadata().entrySet()) { + payload.set(entry.getKey(), MAPPER.valueToTree(entry.getValue())); + } + } + + send("PUT", "/collections/" + config.getCollectionName() + "/points?wait=true", body); + logger.info( + "Indexed {} documents into collection {}", documents.size(), config.getCollectionName()); + return new IndexerResponse(); + } + + private Document toDocument(JsonNode point) { + Map metadata = new HashMap<>(); + String content = ""; + JsonNode payload = point.get("payload"); + if (payload != null && payload.isObject()) { + Map payloadMap = MAPPER.convertValue(payload, Map.class); + for (Map.Entry entry : payloadMap.entrySet()) { + if (entry.getKey().equals(config.getTextPayloadKey())) { + content = entry.getValue() != null ? entry.getValue().toString() : ""; + } else { + metadata.put(entry.getKey(), entry.getValue()); + } + } + } + if (point.get("id") != null) { + metadata.put("id", point.get("id").asText()); + } + if (point.get("score") != null && point.get("score").isNumber()) { + metadata.put("score", point.get("score").asDouble()); + } + Document doc = new Document(content); + doc.setMetadata(metadata); + return doc; + } + + private ArrayNode floatsToArray(List values) { + ArrayNode arr = MAPPER.createArrayNode(); + for (float v : values) { + arr.add(v); + } + return arr; + } + + private List generateEmbedding(ActionContext ctx, String text) { + EmbedResponse response = embedder.run(ctx, new EmbedRequest(List.of(new Document(text)))); + if (response.getEmbeddings() == null || response.getEmbeddings().isEmpty()) { + throw new RuntimeException("Failed to generate embedding for text"); + } + float[] values = response.getEmbeddings().get(0).getValues(); + List out = new ArrayList<>(values.length); + for (float v : values) { + out.add(v); + } + return out; + } + + private String getOrGenerateId(Document doc) { + if (doc.getMetadata() != null && doc.getMetadata().get("id") != null) { + String id = doc.getMetadata().get("id").toString(); + // Qdrant point ids must be an unsigned integer or a UUID; derive a stable UUID otherwise. + try { + UUID.fromString(id); + return id; + } catch (IllegalArgumentException e) { + return UUID.nameUUIDFromBytes(id.getBytes(StandardCharsets.UTF_8)).toString(); + } + } + return UUID.randomUUID().toString(); + } + + private JsonNode send(String method, String path, JsonNode body) { + try { + HttpRequest.Builder builder = + HttpRequest.newBuilder() + .uri(URI.create(baseUrl + path)) + .header("Content-Type", "application/json") + .header("Accept", "application/json"); + if (apiKey != null && !apiKey.isBlank()) { + builder.header("api-key", apiKey); + } + if (body != null) { + builder.method( + method, + HttpRequest.BodyPublishers.ofString( + MAPPER.writeValueAsString(body), StandardCharsets.UTF_8)); + } else { + builder.method(method, HttpRequest.BodyPublishers.noBody()); + } + HttpResponse response = + http.send(builder.build(), HttpResponse.BodyHandlers.ofString()); + if (response.statusCode() == 404) { + throw new NotFoundException(path); + } + if (response.statusCode() / 100 != 2) { + throw new RuntimeException( + "Qdrant request " + + method + + " " + + path + + " failed (" + + response.statusCode() + + "): " + + response.body()); + } + String responseBody = response.body(); + if (responseBody == null || responseBody.isBlank()) { + return MAPPER.createObjectNode(); + } + return MAPPER.readTree(responseBody); + } catch (RuntimeException e) { + throw e; + } catch (Exception e) { + throw new RuntimeException( + "Qdrant request " + method + " " + path + " failed: " + e.getMessage(), e); + } + } + + /** Signals a 404 from the Qdrant API (used to detect a missing collection). */ + private static final class NotFoundException extends RuntimeException { + NotFoundException(String path) { + super("Not found: " + path); + } + } +} diff --git a/plugins/qdrant/src/main/java/com/google/genkit/plugins/qdrant/package-info.java b/plugins/qdrant/src/main/java/com/google/genkit/plugins/qdrant/package-info.java new file mode 100644 index 000000000..87061038e --- /dev/null +++ b/plugins/qdrant/src/main/java/com/google/genkit/plugins/qdrant/package-info.java @@ -0,0 +1,25 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed 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. + * + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Qdrant vector database integration for Genkit. + * + *

{@link com.google.genkit.plugins.qdrant.QdrantPlugin} registers retrievers and indexers backed + * by a Qdrant server (REST API) for RAG workflows. + */ +package com.google.genkit.plugins.qdrant; diff --git a/plugins/qdrant/src/test/java/com/google/genkit/plugins/qdrant/QdrantCollectionConfigTest.java b/plugins/qdrant/src/test/java/com/google/genkit/plugins/qdrant/QdrantCollectionConfigTest.java new file mode 100644 index 000000000..b582c82e6 --- /dev/null +++ b/plugins/qdrant/src/test/java/com/google/genkit/plugins/qdrant/QdrantCollectionConfigTest.java @@ -0,0 +1,78 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed 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. + * + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.google.genkit.plugins.qdrant; + +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 org.junit.jupiter.api.Test; + +/** Unit tests for {@link QdrantCollectionConfig}. */ +class QdrantCollectionConfigTest { + + @Test + void defaultsAreSane() { + QdrantCollectionConfig c = + QdrantCollectionConfig.builder().collectionName("films").embedderName("e").build(); + assertEquals("films", c.getCollectionName()); + assertEquals(768, c.getDimension()); + assertEquals(QdrantCollectionConfig.Distance.COSINE, c.getDistance()); + assertEquals("Cosine", c.getDistance().getValue()); + assertEquals("text", c.getTextPayloadKey()); + assertTrue(c.isCreateCollectionIfNotExists()); + } + + @Test + void customBuilder() { + QdrantCollectionConfig c = + QdrantCollectionConfig.builder() + .collectionName("docs") + .embedderName("e") + .dimension(1536) + .distance(QdrantCollectionConfig.Distance.DOT_PRODUCT) + .textPayloadKey("content") + .createCollectionIfNotExists(false) + .addAdditionalMetadata("source", "wiki") + .build(); + assertEquals(1536, c.getDimension()); + assertEquals("Dot", c.getDistance().getValue()); + assertEquals("content", c.getTextPayloadKey()); + assertEquals(false, c.isCreateCollectionIfNotExists()); + assertEquals("wiki", c.getAdditionalMetadata().get("source")); + } + + @Test + void builderValidates() { + assertThrows( + IllegalArgumentException.class, + () -> QdrantCollectionConfig.builder().embedderName("e").build()); + assertThrows( + IllegalArgumentException.class, + () -> QdrantCollectionConfig.builder().collectionName("c").build()); + assertThrows( + IllegalArgumentException.class, + () -> + QdrantCollectionConfig.builder() + .collectionName("c") + .embedderName("e") + .dimension(0) + .build()); + } +} diff --git a/plugins/qdrant/src/test/java/com/google/genkit/plugins/qdrant/QdrantPluginTest.java b/plugins/qdrant/src/test/java/com/google/genkit/plugins/qdrant/QdrantPluginTest.java new file mode 100644 index 000000000..fd3482910 --- /dev/null +++ b/plugins/qdrant/src/test/java/com/google/genkit/plugins/qdrant/QdrantPluginTest.java @@ -0,0 +1,48 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed 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. + * + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.google.genkit.plugins.qdrant; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import org.junit.jupiter.api.Test; + +/** Unit tests for {@link QdrantPlugin}. */ +class QdrantPluginTest { + + private static QdrantCollectionConfig config() { + return QdrantCollectionConfig.builder().collectionName("films").embedderName("e").build(); + } + + @Test + void getName() { + assertEquals("qdrant", QdrantPlugin.builder().addCollection(config()).build().getName()); + } + + @Test + void requiresAtLeastOneCollection() { + assertThrows(IllegalStateException.class, () -> QdrantPlugin.builder().build()); + } + + @Test + void initWithoutRegistryThrows() { + QdrantPlugin plugin = QdrantPlugin.builder().addCollection(config()).build(); + assertThrows(IllegalStateException.class, plugin::init); + } +} diff --git a/plugins/qdrant/src/test/java/com/google/genkit/plugins/qdrant/QdrantVectorStoreTest.java b/plugins/qdrant/src/test/java/com/google/genkit/plugins/qdrant/QdrantVectorStoreTest.java new file mode 100644 index 000000000..123e9a548 --- /dev/null +++ b/plugins/qdrant/src/test/java/com/google/genkit/plugins/qdrant/QdrantVectorStoreTest.java @@ -0,0 +1,110 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed 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. + * + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.google.genkit.plugins.qdrant; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assumptions.assumeTrue; + +import com.google.genkit.ai.Document; +import com.google.genkit.ai.EmbedResponse; +import com.google.genkit.ai.Embedder; +import com.google.genkit.ai.EmbedderInfo; +import com.google.genkit.ai.IndexerRequest; +import com.google.genkit.ai.RetrieverRequest; +import com.google.genkit.ai.RetrieverResponse; +import java.util.ArrayList; +import java.util.List; +import java.util.Random; +import java.util.UUID; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +/** + * Integration tests for {@link QdrantVectorStore}, gated on the {@code QDRANT_URL} environment + * variable (e.g. a Qdrant server started with Docker). Skipped via {@link + * org.junit.jupiter.api.Assumptions} when unset. Uses a deterministic stub embedder so a query + * equal to an indexed document's text retrieves that document first. + */ +class QdrantVectorStoreTest { + + private static final String URL = System.getenv("QDRANT_URL"); + private static final String API_KEY = System.getenv("QDRANT_API_KEY"); + private static final int DIM = 16; + + private QdrantVectorStore store; + + private static boolean configured() { + return URL != null && !URL.isEmpty(); + } + + private static Embedder stubEmbedder() { + return new Embedder( + "test/stub", + new EmbedderInfo(), + (ctx, req) -> { + List out = new ArrayList<>(); + for (Document doc : req.getDocuments()) { + Random random = new Random(doc.text().hashCode()); + float[] values = new float[DIM]; + for (int i = 0; i < DIM; i++) { + values[i] = random.nextFloat(); + } + out.add(new EmbedResponse.Embedding(values)); + } + return new EmbedResponse(out); + }); + } + + @BeforeEach + void setUp() { + if (!configured()) { + return; + } + QdrantCollectionConfig config = + QdrantCollectionConfig.builder() + .collectionName("genkit_test_" + UUID.randomUUID().toString().replace("-", "")) + .embedderName("test/stub") + .dimension(DIM) + .build(); + store = new QdrantVectorStore(URL, API_KEY, config, stubEmbedder()); + } + + @Test + void indexThenRetrieveReturnsNearestFirst() { + assumeTrue(configured()); + List docs = + List.of( + Document.fromText("The Matrix is a sci-fi film about simulated reality."), + Document.fromText("The Godfather is a crime film about a mafia family."), + Document.fromText("Inception is a sci-fi film about dreams.")); + store.index(null, new IndexerRequest(docs)); + + RetrieverRequest request = new RetrieverRequest(Document.fromText(docs.get(1).text())); + RetrieverRequest.RetrieverOptions options = new RetrieverRequest.RetrieverOptions(); + options.setK(3); + request.setOptions(options); + + RetrieverResponse response = store.retrieve(null, request); + assertFalse(response.getDocuments().isEmpty()); + assertEquals( + "The Godfather is a crime film about a mafia family.", + response.getDocuments().get(0).text()); + } +} diff --git a/plugins/weaviate/README.md b/plugins/weaviate/README.md index c26ad1ea7..876f7e309 100644 --- a/plugins/weaviate/README.md +++ b/plugins/weaviate/README.md @@ -58,7 +58,7 @@ Genkit genkit = Genkit.builder() .plugin(WeaviatePlugin.local() .addCollection(WeaviateCollectionConfig.builder() .name("documents") - .embedderName("googleai/text-embedding-004") + .embedderName("googleai/gemini-embedding-001") .build()) .build()) .build(); @@ -76,7 +76,7 @@ Genkit genkit = Genkit.builder() .apiKey(System.getenv("WEAVIATE_API_KEY")) .addCollection(WeaviateCollectionConfig.builder() .name("documents") - .embedderName("googleai/text-embedding-004") + .embedderName("googleai/gemini-embedding-001") .distanceMeasure(WeaviateCollectionConfig.DistanceMeasure.COSINE) .createCollectionIfMissing(true) .vectorDimension(768) @@ -137,7 +137,7 @@ Flow ragFlow = genkit.defineFlow("ragQuery", // Generate answer using context ModelResponse response = genkit.generate(GenerateOptions.builder() - .model("googleai/gemini-2.0-flash") + .model("googleai/gemini-2.5-flash") .prompt(question) .docs(docs) .build()); diff --git a/plugins/weaviate/src/main/java/com/google/genkit/plugins/weaviate/WeaviateCollectionConfig.java b/plugins/weaviate/src/main/java/com/google/genkit/plugins/weaviate/WeaviateCollectionConfig.java index a9b8f3e1c..5e29e756e 100644 --- a/plugins/weaviate/src/main/java/com/google/genkit/plugins/weaviate/WeaviateCollectionConfig.java +++ b/plugins/weaviate/src/main/java/com/google/genkit/plugins/weaviate/WeaviateCollectionConfig.java @@ -28,7 +28,7 @@ *

{@code
  * WeaviateCollectionConfig config = WeaviateCollectionConfig.builder()
  *     .name("documents")
- *     .embedderName("googleai/text-embedding-004")
+ *     .embedderName("googleai/gemini-embedding-001")
  *     .distanceMeasure(DistanceMeasure.COSINE)
  *     .createCollectionIfMissing(true)
  *     .build();
@@ -201,7 +201,7 @@ public Builder embedder(Embedder embedder) {
     /**
      * Sets the embedder name to resolve from registry.
      *
-     * @param embedderName the embedder name (e.g., "googleai/text-embedding-004")
+     * @param embedderName the embedder name (e.g., "googleai/gemini-embedding-001")
      * @return this builder
      */
     public Builder embedderName(String embedderName) {
diff --git a/plugins/weaviate/src/main/java/com/google/genkit/plugins/weaviate/WeaviatePlugin.java b/plugins/weaviate/src/main/java/com/google/genkit/plugins/weaviate/WeaviatePlugin.java
index 96630677c..83f0d60ba 100644
--- a/plugins/weaviate/src/main/java/com/google/genkit/plugins/weaviate/WeaviatePlugin.java
+++ b/plugins/weaviate/src/main/java/com/google/genkit/plugins/weaviate/WeaviatePlugin.java
@@ -56,7 +56,7 @@
  *             .addCollection(
  *                 WeaviateCollectionConfig.builder()
  *                     .name("documents")
- *                     .embedderName("googleai/text-embedding-004")
+ *                     .embedderName("googleai/gemini-embedding-001")
  *                     .build())
  *             .build())
  *     .build();
diff --git a/plugins/weaviate/src/main/java/com/google/genkit/plugins/weaviate/package-info.java b/plugins/weaviate/src/main/java/com/google/genkit/plugins/weaviate/package-info.java
index a1d2ae391..03228f0d6 100644
--- a/plugins/weaviate/src/main/java/com/google/genkit/plugins/weaviate/package-info.java
+++ b/plugins/weaviate/src/main/java/com/google/genkit/plugins/weaviate/package-info.java
@@ -40,7 +40,7 @@
  *             .addCollection(
  *                 WeaviateCollectionConfig.builder()
  *                     .name("documents")
- *                     .embedderName("googleai/text-embedding-004")
+ *                     .embedderName("googleai/gemini-embedding-001")
  *                     .build())
  *             .build())
  *     .build();
diff --git a/pom.xml b/pom.xml
index d9d125dd9..e9a79e558 100644
--- a/pom.xml
+++ b/pom.xml
@@ -87,6 +87,9 @@
         plugins/middleware
         plugins/weaviate
         plugins/postgresql
+        plugins/mongodb
+        plugins/chroma
+        plugins/qdrant
         plugins/pinecone
         plugins/evaluators
         plugins/aws-bedrock
@@ -116,11 +119,16 @@
         samples/agents-firestore-session
         samples/agents-dynamodb-session
         samples/agents-cosmos-session
+        samples/agents-postgres-session
+        samples/agents-mongo-session
         samples/spring
         samples/firebase
         samples/weaviate
         samples/postgresql
         samples/pinecone
+        samples/chroma
+        samples/qdrant
+        samples/mongo-vector
         samples/structured-output
         samples/aws-bedrock
         samples/azure-foundry
diff --git a/samples/README.md b/samples/README.md
index f258a0715..0e910b6ef 100644
--- a/samples/README.md
+++ b/samples/README.md
@@ -77,10 +77,15 @@ The Dev UI will be available at `http://localhost:4000` and allows you to:
 | [weaviate](./weaviate) | Weaviate vector database RAG sample | `OPENAI_API_KEY` |
 | [postgresql](./postgresql) | PostgreSQL pgvector RAG sample | `OPENAI_API_KEY` |
 | [pinecone](./pinecone) | Pinecone vector database RAG sample | `OPENAI_API_KEY` + `PINECONE_API_KEY` |
+| [chroma](./chroma) | Chroma vector database RAG sample | `GEMINI_API_KEY` + Chroma |
+| [qdrant](./qdrant) | Qdrant vector database RAG sample | `GEMINI_API_KEY` + Qdrant |
+| [mongo-vector](./mongo-vector) | MongoDB Atlas Vector Search RAG sample | `GEMINI_API_KEY` + MongoDB Atlas |
 | [agents-human-in-the-loop](./agents-human-in-the-loop) | Agent interrupts + human-in-the-loop resume | `GEMINI_API_KEY` |
 | [agents-firestore-session](./agents-firestore-session) | Agent session persistence backed by Firestore | `GEMINI_API_KEY` + Firestore |
 | [agents-dynamodb-session](./agents-dynamodb-session) | Agent session persistence backed by DynamoDB | AWS credentials + DynamoDB |
 | [agents-cosmos-session](./agents-cosmos-session) | Agent session persistence backed by Azure Cosmos DB | Azure credentials + Cosmos DB |
+| [agents-postgres-session](./agents-postgres-session) | Agent session persistence backed by PostgreSQL | `GEMINI_API_KEY` + PostgreSQL |
+| [agents-mongo-session](./agents-mongo-session) | Agent session persistence backed by MongoDB | `GEMINI_API_KEY` + MongoDB |
 
 ## Sample Details
 
diff --git a/samples/agents-mongo-session/README.md b/samples/agents-mongo-session/README.md
new file mode 100644
index 000000000..2e31fd3bf
--- /dev/null
+++ b/samples/agents-mongo-session/README.md
@@ -0,0 +1,65 @@
+# Agents: MongoDB Session Sample
+
+A server-managed assistant agent whose conversation state is persisted in **MongoDB** via `MongoSessionStore`, using a **Gemini** model for generation.
+
+## Prerequisites
+
+- Java 21+ and Maven 3.6+
+- A `GEMINI_API_KEY` (for live model calls) — get one from [Google AI Studio](https://aistudio.google.com/apikey)
+- A reachable MongoDB instance (see the Docker command below)
+
+## Run MongoDB locally with Docker
+
+Start a throwaway MongoDB container:
+
+```bash
+docker run --detach \
+  --name genkit-mongo \
+  --publish 27017:27017 \
+  mongo:8
+
+# wait until it's accepting connections
+until docker exec genkit-mongo mongosh --quiet --eval 'db.runCommand({ ping: 1 })' >/dev/null 2>&1; do sleep 1; done
+echo "mongo ready"
+```
+
+The store creates its database (`genkit`) and collection (`genkit_sessions`) automatically on first write — no setup needed. Stop and remove the container later with:
+
+```bash
+docker rm -f genkit-mongo
+```
+
+## Configure
+
+```bash
+export MONGO_URI=mongodb://localhost:27017
+export GEMINI_API_KEY=
+```
+
+### Inspecting the stored data
+
+```bash
+docker exec -it genkit-mongo mongosh genkit \
+  --quiet --eval 'db.genkit_sessions.find({}, { _id: 1 }).toArray()'
+```
+
+Documents are keyed by `_id` = `::` (prefix defaults to `global`): `SNAP_` (snapshot metadata), `SHARD__` (the state JSON), and `PTR_` (the current-leaf pointer). Each document carries a `version` field used for optimistic concurrency.
+
+## Run
+
+**Serve over HTTP (default):**
+
+```bash
+mvn -q exec:java
+# assistant -> POST http://localhost:8084/assistant
+```
+
+**Genkit Dev UI:**
+
+```bash
+genkit start -- mvn -q exec:java
+```
+
+## Configuration
+
+`MongoSessionStore` uses a sharded checkpoint + diff + pointer layout in a single collection. Tune it with `MongoSessionStoreOptions` (database/collection names, checkpoint interval, shard size — default 1 MiB, under the 16 MB document cap — per-tenant prefix).
diff --git a/samples/agents-mongo-session/pom.xml b/samples/agents-mongo-session/pom.xml
new file mode 100644
index 000000000..4e66b505f
--- /dev/null
+++ b/samples/agents-mongo-session/pom.xml
@@ -0,0 +1,87 @@
+
+
+
+    4.0.0
+
+    
+        com.google.genkit
+        genkit-parent
+        1.0.0-SNAPSHOT
+        ../../pom.xml
+    
+
+    com.google.genkit.samples
+    genkit-sample-agents-mongo-session
+    jar
+    Genkit Agents Mongo Session Sample
+    Sample demonstrating agent session persistence backed by MongoDB
+
+    
+        UTF-8
+        21
+        21
+        1.0.0-SNAPSHOT
+        true
+        true
+    
+
+    
+        
+            com.google.genkit
+            genkit
+            ${genkit.version}
+        
+        
+            com.google.genkit
+            genkit-plugin-google-genai
+            ${genkit.version}
+        
+        
+            com.google.genkit
+            genkit-plugin-mongodb
+            ${genkit.version}
+        
+        
+            com.google.genkit
+            genkit-plugin-jetty
+            ${genkit.version}
+        
+        
+            ch.qos.logback
+            logback-classic
+            1.5.37
+        
+    
+
+    
+        
+            
+                org.codehaus.mojo
+                exec-maven-plugin
+                3.6.3
+                
+                    com.google.genkit.samples.MongoSessionAgentApp
+                
+            
+        
+    
+
diff --git a/samples/agents-mongo-session/run.sh b/samples/agents-mongo-session/run.sh
new file mode 100755
index 000000000..d10fb3e2a
--- /dev/null
+++ b/samples/agents-mongo-session/run.sh
@@ -0,0 +1,7 @@
+#!/bin/bash
+
+# Genkit Sample Runner
+# This script runs the sample application
+
+echo "Building and running the sample..."
+mvn clean compile exec:java
diff --git a/samples/agents-mongo-session/src/main/java/com/google/genkit/samples/MongoSessionAgentApp.java b/samples/agents-mongo-session/src/main/java/com/google/genkit/samples/MongoSessionAgentApp.java
new file mode 100644
index 000000000..7a2e2f347
--- /dev/null
+++ b/samples/agents-mongo-session/src/main/java/com/google/genkit/samples/MongoSessionAgentApp.java
@@ -0,0 +1,119 @@
+/*
+ * Copyright 2025 Google LLC
+ *
+ * Licensed 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.
+ *
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package com.google.genkit.samples;
+
+import com.google.genkit.Genkit;
+import com.google.genkit.GenkitOptions;
+import com.google.genkit.agent.AgentConfig;
+import com.google.genkit.ai.agent.SessionStore;
+import com.google.genkit.plugins.googlegenai.GoogleGenAIPlugin;
+import com.google.genkit.plugins.jetty.JettyPlugin;
+import com.google.genkit.plugins.mongodb.session.MongoSessionStore;
+import com.google.genkit.plugins.mongodb.session.MongoSessionStoreOptions;
+import com.mongodb.client.MongoClient;
+import com.mongodb.client.MongoClients;
+import java.util.Map;
+
+/**
+ * Agent session persistence backed by MongoDB.
+ *
+ * 

Defines a server-managed assistant whose conversation snapshots are stored in MongoDB via + * {@link MongoSessionStore}. Because the state lives in the database (not in the process), the + * conversation survives restarts and can be resumed from any instance. + * + *

Point it at a local MongoDB (see the README for a one-line Docker command): + * + *

+ *   export MONGO_URI=mongodb://localhost:27017
+ *   export GEMINI_API_KEY=<your-key>
+ * 
+ * + *

Starts Jetty to expose the agent over HTTP and keep the process alive; also discoverable in + * the Genkit Dev UI. {@code POST http://localhost:8084/assistant}. + */ +public class MongoSessionAgentApp { + + public static void main(String[] args) throws Exception { + // ── 1. Build Genkit with the beta agents API enabled ──────────────────── + Genkit genkit = + Genkit.builder() + .options(GenkitOptions.builder().experimental(true).devMode(true).build()) + .plugin(GoogleGenAIPlugin.create()) + .build(); + + // ── 2. Build the MongoDB-backed session store (null if not configured) ─── + SessionStore> store = buildStore(); + + // ── 3. Define a server-managed agent using the store ──────────────────── + AgentConfig.Builder> cfg = + AgentConfig.>builder() + .name("assistant") + .description("A helpful assistant with MongoDB-backed memory") + .system( + "You are a helpful assistant. Keep answers concise and remember what the user tells you.") + .model("googleai/gemini-2.5-flash"); + if (store != null) { + cfg.store(store); + } + genkit.beta().defineAgent(cfg.build()); + + // ── 4. Serve over HTTP + keep the process alive ───────────────────────── + serve(genkit); + } + + /** + * Builds a MongoDB-backed session store, or returns {@code null} (running client-managed) when + * MongoDB is not configured / cannot be reached. Reads {@code MONGO_URI} (connection string, e.g. + * {@code mongodb://localhost:27017}). + */ + private static SessionStore> buildStore() { + String uri = System.getenv("MONGO_URI"); + if (uri == null || uri.isBlank()) { + System.out.println( + "MongoDB not configured (set MONGO_URI); running client-managed. See the README for a" + + " one-line Docker command."); + return null; + } + try { + MongoClient client = MongoClients.create(uri); + return new MongoSessionStore<>(client, MongoSessionStoreOptions.defaults()); + } catch (Exception e) { + System.out.println("MongoDB not reachable (" + e.getMessage() + "); running client-managed."); + return null; + } + } + + /** Starts Jetty to serve the agent over HTTP and blocks until the process is stopped. */ + private static void serve(Genkit genkit) throws Exception { + int port = System.getenv("PORT") != null ? Integer.parseInt(System.getenv("PORT")) : 8084; + JettyPlugin jetty = JettyPlugin.create(port); + jetty.init(genkit.getRegistry()); + System.out.println("Serving the assistant on http://localhost:" + port); + System.out.println(" assistant -> POST http://localhost:" + port + "/assistant"); + System.out.println("Tip: run under `genkit start -- mvn -q exec:java` to open the Dev UI."); + try { + jetty.start(); // blocks until the process is stopped (Ctrl-C) + } catch (Exception e) { + System.err.println( + "ERROR: could not start the HTTP server on port " + port + ": " + e.getMessage()); + System.err.println("Port " + port + " is likely already in use. Free it or set PORT."); + throw e; + } + } +} diff --git a/samples/agents-mongo-session/src/main/resources/logback.xml b/samples/agents-mongo-session/src/main/resources/logback.xml new file mode 100644 index 000000000..42cdb3d0b --- /dev/null +++ b/samples/agents-mongo-session/src/main/resources/logback.xml @@ -0,0 +1,24 @@ + + + + + + %d{HH:mm:ss.SSS} %-5level %logger{24} - %msg%n + + + + + + + + + + + + + + + + + + diff --git a/samples/agents-postgres-session/README.md b/samples/agents-postgres-session/README.md new file mode 100644 index 000000000..a6b3d5d26 --- /dev/null +++ b/samples/agents-postgres-session/README.md @@ -0,0 +1,70 @@ +# Agents: PostgreSQL Session Sample + +A server-managed assistant agent whose conversation state is persisted in **PostgreSQL** via `PostgresSessionStore`, using a **Gemini** model for generation. + +## Prerequisites + +- Java 21+ and Maven 3.6+ +- A `GEMINI_API_KEY` (for live model calls) — get one from [Google AI Studio](https://aistudio.google.com/apikey) +- A reachable PostgreSQL instance (see the Docker command below) + +## Run PostgreSQL locally with Docker + +Start a throwaway Postgres container: + +```bash +docker run --detach \ + --name genkit-postgres \ + --env POSTGRES_USER=postgres \ + --env POSTGRES_PASSWORD=postgres \ + --env POSTGRES_DB=genkit \ + --publish 5432:5432 \ + postgres:18 + +# wait until it's accepting connections +until docker exec genkit-postgres pg_isready -U postgres >/dev/null 2>&1; do sleep 1; done +echo "postgres ready" +``` + +The store creates its table (`genkit_sessions`) automatically on first use — no schema setup needed. Stop and remove the container later with: + +```bash +docker rm -f genkit-postgres +``` + +## Configure + +```bash +export POSTGRES_URL=jdbc:postgresql://localhost:5432/genkit +export POSTGRES_USER=postgres # optional (default: postgres) +export POSTGRES_PASSWORD=postgres # optional (default: postgres) +export GEMINI_API_KEY= +``` + +### Inspecting the stored data + +```bash +docker exec -it genkit-postgres psql -U postgres -d genkit \ + -c "SELECT id FROM genkit_sessions;" +``` + +Rows are keyed by `id`: `SNAP_` (snapshot metadata), `SHARD__` (the state JSON), and `PTR_` (the current-leaf pointer). Each row carries a JSONB `doc` payload and a `version` counter used for optimistic concurrency. + +## Run + +**Serve over HTTP (default):** + +```bash +mvn -q exec:java +# assistant -> POST http://localhost:8083/assistant +``` + +**Genkit Dev UI:** + +```bash +genkit start -- mvn -q exec:java +``` + +## Configuration + +`PostgresSessionStore` uses a sharded checkpoint + diff + pointer layout in a single table. Tune it with `PostgresSessionStoreOptions` (table name, checkpoint interval, shard size — default 1 MiB — per-tenant prefix, `createTableIfNotExists`). diff --git a/samples/agents-postgres-session/pom.xml b/samples/agents-postgres-session/pom.xml new file mode 100644 index 000000000..8b385be51 --- /dev/null +++ b/samples/agents-postgres-session/pom.xml @@ -0,0 +1,87 @@ + + + + 4.0.0 + + + com.google.genkit + genkit-parent + 1.0.0-SNAPSHOT + ../../pom.xml + + + com.google.genkit.samples + genkit-sample-agents-postgres-session + jar + Genkit Agents Postgres Session Sample + Sample demonstrating agent session persistence backed by PostgreSQL + + + UTF-8 + 21 + 21 + 1.0.0-SNAPSHOT + true + true + + + + + com.google.genkit + genkit + ${genkit.version} + + + com.google.genkit + genkit-plugin-google-genai + ${genkit.version} + + + com.google.genkit + genkit-plugin-postgresql + ${genkit.version} + + + com.google.genkit + genkit-plugin-jetty + ${genkit.version} + + + ch.qos.logback + logback-classic + 1.5.37 + + + + + + + org.codehaus.mojo + exec-maven-plugin + 3.6.3 + + com.google.genkit.samples.PostgresSessionAgentApp + + + + + diff --git a/samples/agents-postgres-session/run.sh b/samples/agents-postgres-session/run.sh new file mode 100755 index 000000000..d10fb3e2a --- /dev/null +++ b/samples/agents-postgres-session/run.sh @@ -0,0 +1,7 @@ +#!/bin/bash + +# Genkit Sample Runner +# This script runs the sample application + +echo "Building and running the sample..." +mvn clean compile exec:java diff --git a/samples/agents-postgres-session/src/main/java/com/google/genkit/samples/PostgresSessionAgentApp.java b/samples/agents-postgres-session/src/main/java/com/google/genkit/samples/PostgresSessionAgentApp.java new file mode 100644 index 000000000..bd0f98152 --- /dev/null +++ b/samples/agents-postgres-session/src/main/java/com/google/genkit/samples/PostgresSessionAgentApp.java @@ -0,0 +1,135 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed 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. + * + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.google.genkit.samples; + +import com.google.genkit.Genkit; +import com.google.genkit.GenkitOptions; +import com.google.genkit.agent.AgentConfig; +import com.google.genkit.ai.agent.SessionStore; +import com.google.genkit.plugins.googlegenai.GoogleGenAIPlugin; +import com.google.genkit.plugins.jetty.JettyPlugin; +import com.google.genkit.plugins.postgresql.session.PostgresSessionStore; +import com.google.genkit.plugins.postgresql.session.PostgresSessionStoreOptions; +import java.util.Map; +import javax.sql.DataSource; +import org.postgresql.ds.PGSimpleDataSource; + +/** + * Agent session persistence backed by PostgreSQL. + * + *

Defines a server-managed assistant whose conversation snapshots are stored in PostgreSQL via + * {@link PostgresSessionStore}. Because the state lives in the database (not in the process), the + * conversation survives restarts and can be resumed from any instance. + * + *

Point it at a local Postgres (see the README for a one-line Docker command): + * + *

+ *   export POSTGRES_URL=jdbc:postgresql://localhost:5432/genkit
+ *   export POSTGRES_USER=postgres
+ *   export POSTGRES_PASSWORD=postgres
+ *   export GEMINI_API_KEY=<your-key>
+ * 
+ * + *

Starts Jetty to expose the agent over HTTP and keep the process alive; also discoverable in + * the Genkit Dev UI. {@code POST http://localhost:8083/assistant}. + */ +public class PostgresSessionAgentApp { + + public static void main(String[] args) throws Exception { + // ── 1. Build Genkit with the beta agents API enabled ──────────────────── + Genkit genkit = + Genkit.builder() + .options(GenkitOptions.builder().experimental(true).devMode(true).build()) + .plugin(GoogleGenAIPlugin.create()) + .build(); + + // ── 2. Build the Postgres-backed session store (null if not configured) ── + SessionStore> store = buildStore(); + + // ── 3. Define a server-managed agent using the store ──────────────────── + AgentConfig.Builder> cfg = + AgentConfig.>builder() + .name("assistant") + .description("A helpful assistant with PostgreSQL-backed memory") + .system( + "You are a helpful assistant. Keep answers concise and remember what the user tells you.") + .model("googleai/gemini-2.5-flash"); + if (store != null) { + cfg.store(store); + } + genkit.beta().defineAgent(cfg.build()); + + // ── 4. Serve over HTTP + keep the process alive ───────────────────────── + serve(genkit); + } + + /** + * Builds a PostgreSQL-backed session store, or returns {@code null} (running client-managed) when + * PostgreSQL is not configured / cannot be reached. Reads {@code POSTGRES_URL} (JDBC URL, default + * {@code jdbc:postgresql://localhost:5432/genkit}), {@code POSTGRES_USER} (default {@code + * postgres}), and {@code POSTGRES_PASSWORD} (default {@code postgres}). + */ + private static SessionStore> buildStore() { + String url = System.getenv("POSTGRES_URL"); + if (url == null || url.isBlank()) { + System.out.println( + "PostgreSQL not configured (set POSTGRES_URL); running client-managed. See the README" + + " for a one-line Docker command."); + return null; + } + String user = envOrDefault("POSTGRES_USER", "postgres"); + String password = envOrDefault("POSTGRES_PASSWORD", "postgres"); + try { + PGSimpleDataSource ds = new PGSimpleDataSource(); + ds.setUrl(url); + ds.setUser(user); + ds.setPassword(password); + DataSource dataSource = ds; + return new PostgresSessionStore<>( + dataSource, PostgresSessionStoreOptions.builder().createTableIfNotExists(true).build()); + } catch (Exception e) { + System.out.println( + "PostgreSQL not reachable (" + e.getMessage() + "); running client-managed."); + return null; + } + } + + private static String envOrDefault(String name, String fallback) { + String v = System.getenv(name); + return (v != null && !v.isBlank()) ? v : fallback; + } + + /** Starts Jetty to serve the agent over HTTP and blocks until the process is stopped. */ + private static void serve(Genkit genkit) throws Exception { + int port = System.getenv("PORT") != null ? Integer.parseInt(System.getenv("PORT")) : 8083; + JettyPlugin jetty = JettyPlugin.create(port); + jetty.init(genkit.getRegistry()); + System.out.println("Serving the assistant on http://localhost:" + port); + System.out.println(" assistant -> POST http://localhost:" + port + "/assistant"); + System.out.println("Tip: run under `genkit start -- mvn -q exec:java` to open the Dev UI."); + try { + jetty.start(); // blocks until the process is stopped (Ctrl-C) + } catch (Exception e) { + System.err.println( + "ERROR: could not start the HTTP server on port " + port + ": " + e.getMessage()); + System.err.println("Port " + port + " is likely already in use. Free it or set PORT."); + throw e; + } + } +} diff --git a/samples/agents-postgres-session/src/main/resources/logback.xml b/samples/agents-postgres-session/src/main/resources/logback.xml new file mode 100644 index 000000000..ab38a6f29 --- /dev/null +++ b/samples/agents-postgres-session/src/main/resources/logback.xml @@ -0,0 +1,24 @@ + + + + + + %d{HH:mm:ss.SSS} %-5level %logger{24} - %msg%n + + + + + + + + + + + + + + + + + + diff --git a/samples/chroma/README.md b/samples/chroma/README.md new file mode 100644 index 000000000..8f591e007 --- /dev/null +++ b/samples/chroma/README.md @@ -0,0 +1,60 @@ +# Chroma RAG Sample + +A Retrieval-Augmented Generation sample that indexes film descriptions into **Chroma** via `ChromaPlugin` and answers questions with a **Gemini** model. + +## Prerequisites + +- Java 21+ and Maven 3.6+ +- A `GEMINI_API_KEY` — get one from [Google AI Studio](https://aistudio.google.com/apikey) +- A reachable Chroma server (see the Docker command below) + +## Run Chroma locally with Docker + +```bash +docker run --detach \ + --name genkit-chroma \ + --publish 8000:8000 \ + chromadb/chroma + +# wait until it's accepting connections +until curl -sf http://localhost:8000/api/v2/heartbeat >/dev/null 2>&1; do sleep 1; done +echo "chroma ready" +``` + +The collection is created automatically on first use. Stop and remove the container later with: + +```bash +docker rm -f genkit-chroma +``` + +## Configure + +```bash +export GEMINI_API_KEY= +export CHROMA_URL=http://localhost:8000 # optional (default) +export CHROMA_COLLECTION=genkit_films # optional (default) +``` + +## Run + +```bash +mvn -q exec:java +``` + +Then open the Genkit Dev UI at http://localhost:4000 (or run under `genkit start -- mvn -q exec:java`) and exercise the flows: + +- `indexDocuments` — index the sample film descriptions +- `retrieveDocuments` — return the films matching a query +- `ragQuery` — answer a question using retrieved context + +Or via curl: + +```bash +curl -X POST http://localhost:4000/api/flows/indexDocuments -H 'Content-Type: application/json' -d '{}' +curl -X POST http://localhost:4000/api/flows/ragQuery -H 'Content-Type: application/json' \ + -d '{"data": "What Christopher Nolan films are mentioned?"}' +``` + +## Configuration + +`ChromaPlugin` talks to the Chroma v2 REST API. Tune per-collection settings with `ChromaCollectionConfig` (collection name, embedder, distance function, `createCollectionIfNotExists`, additional metadata). diff --git a/samples/chroma/pom.xml b/samples/chroma/pom.xml new file mode 100644 index 000000000..17f77abc9 --- /dev/null +++ b/samples/chroma/pom.xml @@ -0,0 +1,86 @@ + + + + 4.0.0 + + + com.google.genkit + genkit-parent + 1.0.0-SNAPSHOT + ../../pom.xml + + + genkit-sample-chroma + jar + Genkit Chroma RAG Sample + Sample application demonstrating Chroma vector search with Genkit + + + true + com.google.genkit.samples.chroma.ChromaRAGSample + + + + + com.google.genkit + genkit + ${project.version} + + + com.google.genkit + genkit-plugin-chroma + ${project.version} + + + com.google.genkit + genkit-plugin-google-genai + ${project.version} + + + com.google.genkit + genkit-plugin-jetty + ${project.version} + + + ch.qos.logback + logback-classic + + + io.github.cdimascio + dotenv-java + 3.2.0 + + + + + + + org.codehaus.mojo + exec-maven-plugin + 3.6.3 + + ${exec.mainClass} + + + + + diff --git a/samples/chroma/run.sh b/samples/chroma/run.sh new file mode 100755 index 000000000..d10fb3e2a --- /dev/null +++ b/samples/chroma/run.sh @@ -0,0 +1,7 @@ +#!/bin/bash + +# Genkit Sample Runner +# This script runs the sample application + +echo "Building and running the sample..." +mvn clean compile exec:java diff --git a/samples/chroma/src/main/java/com/google/genkit/samples/chroma/ChromaRAGSample.java b/samples/chroma/src/main/java/com/google/genkit/samples/chroma/ChromaRAGSample.java new file mode 100644 index 000000000..ff841d2c0 --- /dev/null +++ b/samples/chroma/src/main/java/com/google/genkit/samples/chroma/ChromaRAGSample.java @@ -0,0 +1,163 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed 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. + * + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.google.genkit.samples.chroma; + +import com.google.genkit.Genkit; +import com.google.genkit.GenkitOptions; +import com.google.genkit.ai.*; +import com.google.genkit.core.Flow; +import com.google.genkit.plugins.chroma.ChromaCollectionConfig; +import com.google.genkit.plugins.chroma.ChromaPlugin; +import com.google.genkit.plugins.googlegenai.GoogleGenAIPlugin; +import com.google.genkit.plugins.jetty.JettyPlugin; +import com.google.genkit.plugins.jetty.JettyPluginOptions; +import io.github.cdimascio.dotenv.Dotenv; +import java.util.List; +import java.util.stream.Collectors; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Sample application demonstrating Chroma vector search with Genkit. + * + *

Indexes a handful of film descriptions into a Chroma collection, retrieves the nearest matches + * for a query, and answers questions with a RAG flow. Requires a running Chroma server (see the + * README for a one-line Docker command) and a {@code GEMINI_API_KEY}. + */ +public class ChromaRAGSample { + + private static final Logger logger = LoggerFactory.getLogger(ChromaRAGSample.class); + + private static final List SAMPLE_DOCUMENTS = + List.of( + "The Godfather is a 1972 crime film directed by Francis Ford Coppola about the Corleone crime family.", + "The Dark Knight is a 2008 superhero film directed by Christopher Nolan featuring Batman against the Joker.", + "Pulp Fiction is a 1994 crime film directed by Quentin Tarantino known for its nonlinear narrative.", + "Inception is a 2010 sci-fi film directed by Christopher Nolan about dream infiltration.", + "The Matrix is a 1999 sci-fi film directed by the Wachowskis exploring simulated reality.", + "Forrest Gump is a 1994 drama directed by Robert Zemeckis about a man's extraordinary life.", + "Star Wars is a 1977 sci-fi film directed by George Lucas set in a galaxy far, far away.", + "The Shawshank Redemption is a 1994 drama about hope and friendship in a prison."); + + private static final String RAG_SYSTEM_PROMPT = + """ + You are a helpful assistant that answers questions based on the provided context documents. + Answer only from the context. If the context is insufficient, say so. + """; + + public static void main(String[] args) { + Dotenv dotenv = Dotenv.configure().ignoreIfMissing().systemProperties().load(); + + String geminiApiKey = getEnv(dotenv, "GEMINI_API_KEY"); + if (geminiApiKey == null) { + logger.error("Please set GEMINI_API_KEY in .env file or environment variable"); + System.exit(1); + } + + String chromaUrl = getEnvOrDefault(dotenv, "CHROMA_URL", "http://localhost:8000"); + String collection = getEnvOrDefault(dotenv, "CHROMA_COLLECTION", "genkit_films"); + + logger.info("Starting Chroma RAG Sample (url={}, collection={})", chromaUrl, collection); + + ChromaPlugin chromaPlugin = + ChromaPlugin.builder() + .url(chromaUrl) + .addCollection( + ChromaCollectionConfig.builder() + .collectionName(collection) + .embedderName("googleai/gemini-embedding-001") + .distance(ChromaCollectionConfig.Distance.COSINE) + .build()) + .build(); + + JettyPlugin jetty = new JettyPlugin(JettyPluginOptions.builder().port(8080).build()); + Genkit genkit = + Genkit.builder() + .options(GenkitOptions.builder().devMode(true).reflectionPort(3100).build()) + .plugin(GoogleGenAIPlugin.create(geminiApiKey)) + .plugin(chromaPlugin) + .plugin(jetty) + .build(); + + String action = "chroma/" + collection; + + Flow indexDocumentsFlow = + genkit.defineFlow( + "indexDocuments", + Void.class, + String.class, + (ctx, input) -> { + List documents = + SAMPLE_DOCUMENTS.stream().map(Document::fromText).collect(Collectors.toList()); + genkit.index(action, documents); + return "Successfully indexed " + documents.size() + " documents"; + }); + + @SuppressWarnings("unchecked") + Flow, Void> retrieveDocumentsFlow = + genkit.defineFlow( + "retrieveDocuments", + String.class, + (Class>) (Class) List.class, + (ctx, query) -> { + List docs = genkit.retrieve(action, query); + return docs.stream().map(Document::text).collect(Collectors.toList()); + }); + + Flow ragQueryFlow = + genkit.defineFlow( + "ragQuery", + String.class, + String.class, + (ctx, question) -> { + List docs = genkit.retrieve(action, question); + ModelResponse response = + genkit.generate( + GenerateOptions.builder() + .model("googleai/gemini-2.5-flash") + .system(RAG_SYSTEM_PROMPT) + .prompt(question) + .docs(docs) + .config(GenerationConfig.builder().temperature(0.3).build()) + .build()); + return response.getText(); + }); + + logger.info( + "Genkit Chroma RAG Sample started. Flows: indexDocuments, retrieveDocuments, ragQuery"); + logger.info("Dev UI: http://localhost:4000 | Reflection: http://localhost:3100"); + + try { + jetty.start(); + } catch (Exception e) { + logger.error("Failed to start Jetty server", e); + System.exit(1); + } + } + + private static String getEnv(Dotenv dotenv, String name) { + String value = dotenv.get(name); + return (value != null && !value.isBlank()) ? value : System.getenv(name); + } + + private static String getEnvOrDefault(Dotenv dotenv, String name, String defaultValue) { + String value = getEnv(dotenv, name); + return (value != null && !value.isBlank()) ? value : defaultValue; + } +} diff --git a/samples/chroma/src/main/resources/logback.xml b/samples/chroma/src/main/resources/logback.xml new file mode 100644 index 000000000..dff2540b6 --- /dev/null +++ b/samples/chroma/src/main/resources/logback.xml @@ -0,0 +1,20 @@ + + + + + %d{HH:mm:ss.SSS} %-5level %logger{24} - %msg%n + + + + + + + + + + + + + + + diff --git a/samples/firebase/README.md b/samples/firebase/README.md index 22d2fd653..0c182e528 100644 --- a/samples/firebase/README.md +++ b/samples/firebase/README.md @@ -144,7 +144,7 @@ Genkit genkit = Genkit.builder() .addRetriever(FirestoreRetrieverConfig.builder() .name("my-docs") .collection("documents") - .embedderName("googleai/text-embedding-004") + .embedderName("googleai/gemini-embedding-001") .vectorField("embedding") .contentField("content") .distanceMeasure(FirestoreRetrieverConfig.DistanceMeasure.COSINE) diff --git a/samples/firebase/src/main/java/com/google/genkit/samples/firebase/FirestoreRAGSample.java b/samples/firebase/src/main/java/com/google/genkit/samples/firebase/FirestoreRAGSample.java index 90995c998..1992fdd6b 100644 --- a/samples/firebase/src/main/java/com/google/genkit/samples/firebase/FirestoreRAGSample.java +++ b/samples/firebase/src/main/java/com/google/genkit/samples/firebase/FirestoreRAGSample.java @@ -120,7 +120,7 @@ public static void main(String[] args) { FirestoreRetrieverConfig.builder() .name("films") .collection("films") - .embedderName("googleai/text-embedding-004") + .embedderName("googleai/gemini-embedding-001") .vectorField("embedding") .contentField("content") .distanceMeasure(FirestoreRetrieverConfig.DistanceMeasure.COSINE) @@ -198,7 +198,7 @@ public static void main(String[] args) { ModelResponse response = genkit.generate( GenerateOptions.builder() - .model("googleai/gemini-2.0-flash") + .model("googleai/gemini-2.5-flash") .system(RAG_SYSTEM_PROMPT) .prompt(question) .docs(docs) diff --git a/samples/firebase/src/main/java/com/google/genkit/samples/firebase/functions/GeneratePoemFunction.java b/samples/firebase/src/main/java/com/google/genkit/samples/firebase/functions/GeneratePoemFunction.java index 329f7c81c..5ae8cac05 100644 --- a/samples/firebase/src/main/java/com/google/genkit/samples/firebase/functions/GeneratePoemFunction.java +++ b/samples/firebase/src/main/java/com/google/genkit/samples/firebase/functions/GeneratePoemFunction.java @@ -69,7 +69,7 @@ public GeneratePoemFunction() { ModelResponse response = genkit.generate( GenerateOptions.builder() - .model("googleai/gemini-2.0-flash") + .model("googleai/gemini-2.5-flash") .prompt("Write a short, creative poem about: " + topic) .config( GenerationConfig.builder().temperature(0.9).maxOutputTokens(500).build()) diff --git a/samples/google-genai/README.md b/samples/google-genai/README.md index 6f8be0c4a..949330d1c 100644 --- a/samples/google-genai/README.md +++ b/samples/google-genai/README.md @@ -118,11 +118,11 @@ The Google GenAI plugin provides access to: | Model | Description | |-------|-------------| -| `googleai/gemini-2.0-flash` | Fast, efficient Gemini model | +| `googleai/gemini-2.5-flash` | Fast, efficient Gemini model | | `googleai/gemini-1.5-pro` | Advanced reasoning capabilities | | `googleai/gemini-1.5-flash` | Balanced speed and capability | | `googleai/imagen-3.0-generate-002` | Image generation | -| `googleai/text-embedding-004` | Text embeddings | +| `googleai/gemini-embedding-001` | Text embeddings | ## Code Highlights @@ -148,7 +148,7 @@ genkit.defineFlow("textGeneration", String.class, String.class, (ctx, prompt) -> { ModelResponse response = genkit.generate( GenerateOptions.builder() - .model("googleai/gemini-2.0-flash") + .model("googleai/gemini-2.5-flash") .prompt(prompt) .config(GenerationConfig.builder() .temperature(0.7) diff --git a/samples/google-genai/src/main/java/com/google/genkit/samples/GoogleGenAIApp.java b/samples/google-genai/src/main/java/com/google/genkit/samples/GoogleGenAIApp.java index 6cb22b403..4558b5d86 100644 --- a/samples/google-genai/src/main/java/com/google/genkit/samples/GoogleGenAIApp.java +++ b/samples/google-genai/src/main/java/com/google/genkit/samples/GoogleGenAIApp.java @@ -145,7 +145,7 @@ private static void defineTextGenerationFlow(Genkit genkit) { ModelResponse response = genkit.generate( GenerateOptions.builder() - .model("googleai/gemini-2.0-flash") + .model("googleai/gemini-2.5-flash") .prompt(prompt) .config(config) .build()); @@ -201,7 +201,7 @@ private static void defineToolCallingFlow(Genkit genkit) { ModelResponse response = genkit.generate( GenerateOptions.builder() - .model("googleai/gemini-2.0-flash") + .model("googleai/gemini-2.5-flash") .prompt(prompt) .tools(List.of(weatherTool)) .build()); @@ -217,7 +217,7 @@ private static void defineEmbeddingsFlow(Genkit genkit) { String.class, (ctx, text) -> { List documents = Arrays.asList(Document.fromText(text)); - EmbedResponse response = genkit.embed("googleai/text-embedding-004", documents); + EmbedResponse response = genkit.embed("googleai/gemini-embedding-001", documents); if (response.getEmbeddings() != null && !response.getEmbeddings().isEmpty()) { EmbedResponse.Embedding embedding = response.getEmbeddings().get(0); diff --git a/samples/mongo-vector/README.md b/samples/mongo-vector/README.md new file mode 100644 index 000000000..6f8461088 --- /dev/null +++ b/samples/mongo-vector/README.md @@ -0,0 +1,65 @@ +# MongoDB Vector RAG Sample + +A Retrieval-Augmented Generation sample that indexes film descriptions into **MongoDB Atlas Vector Search** via `MongoPlugin` and answers questions with a **Gemini** model. + +## Prerequisites + +- Java 21+ and Maven 3.6+ +- A `GEMINI_API_KEY` — get one from [Google AI Studio](https://aistudio.google.com/apikey) +- A MongoDB deployment that supports Atlas Vector Search — a MongoDB Atlas cluster or the local Atlas image (see below). A plain `mongo` server does **not** support `$vectorSearch`. + +## Run MongoDB Atlas locally with Docker + +Use the official Atlas Local image, which bundles the search/vector engine: + +```bash +docker run --detach \ + --name genkit-atlas \ + --publish 27017:27017 \ + mongodb/mongodb-atlas-local:latest + +# wait until it's accepting connections +until docker exec genkit-atlas mongosh --quiet --eval 'db.runCommand({ ping: 1 })' >/dev/null 2>&1; do sleep 1; done +echo "atlas-local ready" +``` + +The database (`genkit`), collection (`films`), and the Atlas Vector Search index are created automatically on first use. The index takes a few seconds to become queryable. Stop and remove the container later with: + +```bash +docker rm -f genkit-atlas +``` + +## Configure + +```bash +export GEMINI_API_KEY= +export MONGO_URI="mongodb://localhost:27017/?directConnection=true" # optional (default) +export MONGO_DATABASE=genkit # optional (default) +export MONGO_COLLECTION=films # optional (default) +``` + +For a MongoDB Atlas cluster, set `MONGO_URI` to your `mongodb+srv://...` connection string. + +## Run + +```bash +mvn -q exec:java +``` + +Then open the Genkit Dev UI at http://localhost:4000 (or run under `genkit start -- mvn -q exec:java`) and exercise the flows: + +- `indexDocuments` — index the sample film descriptions (also creates the vector index on first run) +- `retrieveDocuments` — return the films matching a query +- `ragQuery` — answer a question using retrieved context + +Or via curl: + +```bash +curl -X POST http://localhost:8080/api/flows/indexDocuments -H 'Content-Type: application/json' -d '{}' +curl -X POST http://localhost:8080/api/flows/ragQuery -H 'Content-Type: application/json' \ + -d '{"data": "What Christopher Nolan films are mentioned?"}' +``` + +## Configuration + +`MongoPlugin` uses the `$vectorSearch` aggregation stage. Tune per-collection settings with `MongoVectorStoreConfig` (database/collection names, embedder, index name, dimension, similarity, text/embedding field names, `numCandidates`, `createIndexIfNotExists`). diff --git a/samples/mongo-vector/pom.xml b/samples/mongo-vector/pom.xml new file mode 100644 index 000000000..0bb281c67 --- /dev/null +++ b/samples/mongo-vector/pom.xml @@ -0,0 +1,86 @@ + + + + 4.0.0 + + + com.google.genkit + genkit-parent + 1.0.0-SNAPSHOT + ../../pom.xml + + + genkit-sample-mongo-vector + jar + Genkit MongoDB Vector RAG Sample + Sample application demonstrating MongoDB Atlas Vector Search with Genkit + + + true + com.google.genkit.samples.mongo.MongoVectorRAGSample + + + + + com.google.genkit + genkit + ${project.version} + + + com.google.genkit + genkit-plugin-mongodb + ${project.version} + + + com.google.genkit + genkit-plugin-google-genai + ${project.version} + + + com.google.genkit + genkit-plugin-jetty + ${project.version} + + + ch.qos.logback + logback-classic + + + io.github.cdimascio + dotenv-java + 3.2.0 + + + + + + + org.codehaus.mojo + exec-maven-plugin + 3.6.3 + + ${exec.mainClass} + + + + + diff --git a/samples/mongo-vector/run.sh b/samples/mongo-vector/run.sh new file mode 100755 index 000000000..d10fb3e2a --- /dev/null +++ b/samples/mongo-vector/run.sh @@ -0,0 +1,7 @@ +#!/bin/bash + +# Genkit Sample Runner +# This script runs the sample application + +echo "Building and running the sample..." +mvn clean compile exec:java diff --git a/samples/mongo-vector/src/main/java/com/google/genkit/samples/mongo/MongoVectorRAGSample.java b/samples/mongo-vector/src/main/java/com/google/genkit/samples/mongo/MongoVectorRAGSample.java new file mode 100644 index 000000000..16ed36d2c --- /dev/null +++ b/samples/mongo-vector/src/main/java/com/google/genkit/samples/mongo/MongoVectorRAGSample.java @@ -0,0 +1,171 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed 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. + * + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.google.genkit.samples.mongo; + +import com.google.genkit.Genkit; +import com.google.genkit.GenkitOptions; +import com.google.genkit.ai.*; +import com.google.genkit.core.Flow; +import com.google.genkit.plugins.googlegenai.GoogleGenAIPlugin; +import com.google.genkit.plugins.jetty.JettyPlugin; +import com.google.genkit.plugins.jetty.JettyPluginOptions; +import com.google.genkit.plugins.mongodb.MongoPlugin; +import com.google.genkit.plugins.mongodb.MongoVectorStoreConfig; +import io.github.cdimascio.dotenv.Dotenv; +import java.util.List; +import java.util.stream.Collectors; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Sample application demonstrating MongoDB Atlas Vector Search with Genkit. + * + *

Indexes a handful of film descriptions into a MongoDB collection, retrieves the nearest + * matches for a query with the {@code $vectorSearch} stage, and answers questions with a RAG flow. + * Requires a MongoDB Atlas cluster or the {@code mongodb/mongodb-atlas-local} Docker image (see the + * README) and a {@code GEMINI_API_KEY}. + */ +public class MongoVectorRAGSample { + + private static final Logger logger = LoggerFactory.getLogger(MongoVectorRAGSample.class); + + private static final List SAMPLE_DOCUMENTS = + List.of( + "The Godfather is a 1972 crime film directed by Francis Ford Coppola about the Corleone crime family.", + "The Dark Knight is a 2008 superhero film directed by Christopher Nolan featuring Batman against the Joker.", + "Pulp Fiction is a 1994 crime film directed by Quentin Tarantino known for its nonlinear narrative.", + "Inception is a 2010 sci-fi film directed by Christopher Nolan about dream infiltration.", + "The Matrix is a 1999 sci-fi film directed by the Wachowskis exploring simulated reality.", + "Forrest Gump is a 1994 drama directed by Robert Zemeckis about a man's extraordinary life.", + "Star Wars is a 1977 sci-fi film directed by George Lucas set in a galaxy far, far away.", + "The Shawshank Redemption is a 1994 drama about hope and friendship in a prison."); + + private static final String RAG_SYSTEM_PROMPT = + """ + You are a helpful assistant that answers questions based on the provided context documents. + Answer only from the context. If the context is insufficient, say so. + """; + + public static void main(String[] args) { + Dotenv dotenv = Dotenv.configure().ignoreIfMissing().systemProperties().load(); + + String geminiApiKey = getEnv(dotenv, "GEMINI_API_KEY"); + if (geminiApiKey == null) { + logger.error("Please set GEMINI_API_KEY in .env file or environment variable"); + System.exit(1); + } + + String mongoUri = + getEnvOrDefault(dotenv, "MONGO_URI", "mongodb://localhost:27017/?directConnection=true"); + String database = getEnvOrDefault(dotenv, "MONGO_DATABASE", "genkit"); + String collection = getEnvOrDefault(dotenv, "MONGO_COLLECTION", "films"); + + logger.info("Starting MongoDB Vector RAG Sample (db={}, collection={})", database, collection); + + MongoPlugin mongoPlugin = + MongoPlugin.builder() + .connectionString(mongoUri) + .addCollection( + MongoVectorStoreConfig.builder() + .databaseName(database) + .collectionName(collection) + .embedderName("googleai/gemini-embedding-001") + .dimension(768) + .similarity(MongoVectorStoreConfig.Similarity.COSINE) + .createIndexIfNotExists(true) + .build()) + .build(); + + JettyPlugin jetty = new JettyPlugin(JettyPluginOptions.builder().port(8080).build()); + Genkit genkit = + Genkit.builder() + .options(GenkitOptions.builder().devMode(true).reflectionPort(3100).build()) + .plugin(GoogleGenAIPlugin.create(geminiApiKey)) + .plugin(mongoPlugin) + .plugin(jetty) + .build(); + + String action = "mongodb/" + collection; + + Flow indexDocumentsFlow = + genkit.defineFlow( + "indexDocuments", + Void.class, + String.class, + (ctx, input) -> { + List documents = + SAMPLE_DOCUMENTS.stream().map(Document::fromText).collect(Collectors.toList()); + genkit.index(action, documents); + return "Successfully indexed " + documents.size() + " documents"; + }); + + @SuppressWarnings("unchecked") + Flow, Void> retrieveDocumentsFlow = + genkit.defineFlow( + "retrieveDocuments", + String.class, + (Class>) (Class) List.class, + (ctx, query) -> { + List docs = genkit.retrieve(action, query); + return docs.stream().map(Document::text).collect(Collectors.toList()); + }); + + Flow ragQueryFlow = + genkit.defineFlow( + "ragQuery", + String.class, + String.class, + (ctx, question) -> { + List docs = genkit.retrieve(action, question); + ModelResponse response = + genkit.generate( + GenerateOptions.builder() + .model("googleai/gemini-2.5-flash") + .system(RAG_SYSTEM_PROMPT) + .prompt(question) + .docs(docs) + .config(GenerationConfig.builder().temperature(0.3).build()) + .build()); + return response.getText(); + }); + + logger.info( + "Genkit MongoDB Vector RAG Sample started. Flows: indexDocuments, retrieveDocuments, ragQuery"); + logger.info("Dev UI: http://localhost:4000 | Reflection: http://localhost:3100"); + logger.info( + "Note: the Atlas Vector Search index is created on first index/retrieve and may take a moment to become queryable."); + + try { + jetty.start(); + } catch (Exception e) { + logger.error("Failed to start Jetty server", e); + System.exit(1); + } + } + + private static String getEnv(Dotenv dotenv, String name) { + String value = dotenv.get(name); + return (value != null && !value.isBlank()) ? value : System.getenv(name); + } + + private static String getEnvOrDefault(Dotenv dotenv, String name, String defaultValue) { + String value = getEnv(dotenv, name); + return (value != null && !value.isBlank()) ? value : defaultValue; + } +} diff --git a/samples/mongo-vector/src/main/resources/logback.xml b/samples/mongo-vector/src/main/resources/logback.xml new file mode 100644 index 000000000..47172d8f9 --- /dev/null +++ b/samples/mongo-vector/src/main/resources/logback.xml @@ -0,0 +1,21 @@ + + + + + %d{HH:mm:ss.SSS} %-5level %logger{24} - %msg%n + + + + + + + + + + + + + + + + diff --git a/samples/pinecone/README.md b/samples/pinecone/README.md index da8faa3c1..4dfdcca16 100644 --- a/samples/pinecone/README.md +++ b/samples/pinecone/README.md @@ -131,8 +131,8 @@ curl -X POST http://localhost:4000/api/flows/ragQuery \ ## Configuration The sample uses: -- **Embedder**: `googleai/text-embedding-004` (768 dimensions) -- **LLM**: `googleai/gemini-2.0-flash` +- **Embedder**: `googleai/gemini-embedding-001` (768 dimensions) +- **LLM**: `googleai/gemini-2.5-flash` - **Metric**: Cosine similarity - **Index Type**: Serverless (AWS us-east-1) @@ -146,7 +146,7 @@ Pinecone supports namespaces for multi-tenant applications. To use namespaces: .addIndex(PineconeIndexConfig.builder() .indexName("my-index") .namespace("production") // Add namespace - .embedderName("googleai/text-embedding-004") + .embedderName("googleai/gemini-embedding-001") .build()) ``` diff --git a/samples/pinecone/src/main/java/com/google/genkit/samples/pinecone/PineconeRAGSample.java b/samples/pinecone/src/main/java/com/google/genkit/samples/pinecone/PineconeRAGSample.java index 45bafa9cf..b9a768f02 100644 --- a/samples/pinecone/src/main/java/com/google/genkit/samples/pinecone/PineconeRAGSample.java +++ b/samples/pinecone/src/main/java/com/google/genkit/samples/pinecone/PineconeRAGSample.java @@ -118,7 +118,7 @@ public static void main(String[] args) { .addIndex( PineconeIndexConfig.builder() .indexName(finalIndexName) - .embedderName("googleai/text-embedding-004") + .embedderName("googleai/gemini-embedding-001") .dimension(768) .metric(PineconeIndexConfig.Metric.COSINE) .cloud(cloudEnum) @@ -199,7 +199,7 @@ public static void main(String[] args) { ModelResponse response = genkit.generate( GenerateOptions.builder() - .model("googleai/gemini-2.0-flash") + .model("googleai/gemini-2.5-flash") .system(RAG_SYSTEM_PROMPT) .prompt(question) .docs(docs) diff --git a/samples/postgresql/README.md b/samples/postgresql/README.md index a593ad4d2..f5339058a 100644 --- a/samples/postgresql/README.md +++ b/samples/postgresql/README.md @@ -140,8 +140,8 @@ WITH (lists = 100); ## Configuration The sample uses: -- **Embedder**: `googleai/text-embedding-004` (768 dimensions) -- **LLM**: `googleai/gemini-2.0-flash` +- **Embedder**: `googleai/gemini-embedding-001` (768 dimensions) +- **LLM**: `googleai/gemini-2.5-flash` - **Distance Strategy**: Cosine distance - **Table Name**: `films` - **Index Type**: IVFFlat with 100 lists diff --git a/samples/postgresql/src/main/java/com/google/genkit/samples/postgresql/PostgresRAGSample.java b/samples/postgresql/src/main/java/com/google/genkit/samples/postgresql/PostgresRAGSample.java index 8d562f79c..f023f1013 100644 --- a/samples/postgresql/src/main/java/com/google/genkit/samples/postgresql/PostgresRAGSample.java +++ b/samples/postgresql/src/main/java/com/google/genkit/samples/postgresql/PostgresRAGSample.java @@ -116,7 +116,7 @@ public static void main(String[] args) { .addTable( PostgresTableConfig.builder() .tableName("films") - .embedderName("googleai/text-embedding-004") + .embedderName("googleai/gemini-embedding-001") .vectorDimension(768) .distanceStrategy(PostgresTableConfig.DistanceStrategy.L2) .createTableIfNotExists(true) @@ -194,7 +194,7 @@ public static void main(String[] args) { ModelResponse response = genkit.generate( GenerateOptions.builder() - .model("googleai/gemini-2.0-flash") + .model("googleai/gemini-2.5-flash") .system(RAG_SYSTEM_PROMPT) .prompt(question) .docs(docs) diff --git a/samples/qdrant/README.md b/samples/qdrant/README.md new file mode 100644 index 000000000..9b0423c0d --- /dev/null +++ b/samples/qdrant/README.md @@ -0,0 +1,63 @@ +# Qdrant RAG Sample + +A Retrieval-Augmented Generation sample that indexes film descriptions into **Qdrant** via `QdrantPlugin` and answers questions with a **Gemini** model. + +## Prerequisites + +- Java 21+ and Maven 3.6+ +- A `GEMINI_API_KEY` — get one from [Google AI Studio](https://aistudio.google.com/apikey) +- A reachable Qdrant server (see the Docker command below) + +## Run Qdrant locally with Docker + +```bash +docker run --detach \ + --name genkit-qdrant \ + --publish 6333:6333 \ + qdrant/qdrant + +# wait until it's accepting connections +until curl -sf http://localhost:6333/readyz >/dev/null 2>&1; do sleep 1; done +echo "qdrant ready" +``` + +The collection is created automatically on first use (dimension 768, cosine distance). Stop and remove the container later with: + +```bash +docker rm -f genkit-qdrant +``` + +## Configure + +```bash +export GEMINI_API_KEY= +export QDRANT_URL=http://localhost:6333 # optional (default) +export QDRANT_API_KEY= # optional (required for Qdrant Cloud) +export QDRANT_COLLECTION=genkit_films # optional (default) +``` + +## Run + +```bash +mvn -q exec:java +``` + +Then open the Genkit Dev UI at http://localhost:4000 (or run under `genkit start -- mvn -q exec:java`) and exercise the flows: + +- `indexDocuments` — index the sample film descriptions +- `retrieveDocuments` — return the films matching a query +- `ragQuery` — answer a question using retrieved context + +Or via curl: + +```bash +curl -X POST http://localhost:4000/api/flows/indexDocuments -H 'Content-Type: application/json' -d '{}' +curl -X POST http://localhost:4000/api/flows/ragQuery -H 'Content-Type: application/json' \ + -d '{"data": "What Christopher Nolan films are mentioned?"}' +``` + +Inspect stored points in the Qdrant dashboard at http://localhost:6333/dashboard. + +## Configuration + +`QdrantPlugin` talks to the Qdrant REST API. Tune per-collection settings with `QdrantCollectionConfig` (collection name, embedder, dimension, distance function, text payload key, `createCollectionIfNotExists`, additional metadata). diff --git a/samples/qdrant/pom.xml b/samples/qdrant/pom.xml new file mode 100644 index 000000000..3ca14a4cf --- /dev/null +++ b/samples/qdrant/pom.xml @@ -0,0 +1,86 @@ + + + + 4.0.0 + + + com.google.genkit + genkit-parent + 1.0.0-SNAPSHOT + ../../pom.xml + + + genkit-sample-qdrant + jar + Genkit Qdrant RAG Sample + Sample application demonstrating Qdrant vector search with Genkit + + + true + com.google.genkit.samples.qdrant.QdrantRAGSample + + + + + com.google.genkit + genkit + ${project.version} + + + com.google.genkit + genkit-plugin-qdrant + ${project.version} + + + com.google.genkit + genkit-plugin-google-genai + ${project.version} + + + com.google.genkit + genkit-plugin-jetty + ${project.version} + + + ch.qos.logback + logback-classic + + + io.github.cdimascio + dotenv-java + 3.2.0 + + + + + + + org.codehaus.mojo + exec-maven-plugin + 3.6.3 + + ${exec.mainClass} + + + + + diff --git a/samples/qdrant/run.sh b/samples/qdrant/run.sh new file mode 100755 index 000000000..d10fb3e2a --- /dev/null +++ b/samples/qdrant/run.sh @@ -0,0 +1,7 @@ +#!/bin/bash + +# Genkit Sample Runner +# This script runs the sample application + +echo "Building and running the sample..." +mvn clean compile exec:java diff --git a/samples/qdrant/src/main/java/com/google/genkit/samples/qdrant/QdrantRAGSample.java b/samples/qdrant/src/main/java/com/google/genkit/samples/qdrant/QdrantRAGSample.java new file mode 100644 index 000000000..396a131b3 --- /dev/null +++ b/samples/qdrant/src/main/java/com/google/genkit/samples/qdrant/QdrantRAGSample.java @@ -0,0 +1,166 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed 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. + * + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.google.genkit.samples.qdrant; + +import com.google.genkit.Genkit; +import com.google.genkit.GenkitOptions; +import com.google.genkit.ai.*; +import com.google.genkit.core.Flow; +import com.google.genkit.plugins.googlegenai.GoogleGenAIPlugin; +import com.google.genkit.plugins.jetty.JettyPlugin; +import com.google.genkit.plugins.jetty.JettyPluginOptions; +import com.google.genkit.plugins.qdrant.QdrantCollectionConfig; +import com.google.genkit.plugins.qdrant.QdrantPlugin; +import io.github.cdimascio.dotenv.Dotenv; +import java.util.List; +import java.util.stream.Collectors; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Sample application demonstrating Qdrant vector search with Genkit. + * + *

Indexes a handful of film descriptions into a Qdrant collection, retrieves the nearest matches + * for a query, and answers questions with a RAG flow. Requires a running Qdrant server (see the + * README for a one-line Docker command) and a {@code GEMINI_API_KEY}. + */ +public class QdrantRAGSample { + + private static final Logger logger = LoggerFactory.getLogger(QdrantRAGSample.class); + + private static final List SAMPLE_DOCUMENTS = + List.of( + "The Godfather is a 1972 crime film directed by Francis Ford Coppola about the Corleone crime family.", + "The Dark Knight is a 2008 superhero film directed by Christopher Nolan featuring Batman against the Joker.", + "Pulp Fiction is a 1994 crime film directed by Quentin Tarantino known for its nonlinear narrative.", + "Inception is a 2010 sci-fi film directed by Christopher Nolan about dream infiltration.", + "The Matrix is a 1999 sci-fi film directed by the Wachowskis exploring simulated reality.", + "Forrest Gump is a 1994 drama directed by Robert Zemeckis about a man's extraordinary life.", + "Star Wars is a 1977 sci-fi film directed by George Lucas set in a galaxy far, far away.", + "The Shawshank Redemption is a 1994 drama about hope and friendship in a prison."); + + private static final String RAG_SYSTEM_PROMPT = + """ + You are a helpful assistant that answers questions based on the provided context documents. + Answer only from the context. If the context is insufficient, say so. + """; + + public static void main(String[] args) { + Dotenv dotenv = Dotenv.configure().ignoreIfMissing().systemProperties().load(); + + String geminiApiKey = getEnv(dotenv, "GEMINI_API_KEY"); + if (geminiApiKey == null) { + logger.error("Please set GEMINI_API_KEY in .env file or environment variable"); + System.exit(1); + } + + String qdrantUrl = getEnvOrDefault(dotenv, "QDRANT_URL", "http://localhost:6333"); + String qdrantApiKey = getEnv(dotenv, "QDRANT_API_KEY"); // optional + String collection = getEnvOrDefault(dotenv, "QDRANT_COLLECTION", "genkit_films"); + + logger.info("Starting Qdrant RAG Sample (url={}, collection={})", qdrantUrl, collection); + + QdrantPlugin qdrantPlugin = + QdrantPlugin.builder() + .url(qdrantUrl) + .apiKey(qdrantApiKey) + .addCollection( + QdrantCollectionConfig.builder() + .collectionName(collection) + .embedderName("googleai/gemini-embedding-001") + .dimension(768) + .distance(QdrantCollectionConfig.Distance.COSINE) + .build()) + .build(); + + JettyPlugin jetty = new JettyPlugin(JettyPluginOptions.builder().port(8080).build()); + Genkit genkit = + Genkit.builder() + .options(GenkitOptions.builder().devMode(true).reflectionPort(3100).build()) + .plugin(GoogleGenAIPlugin.create(geminiApiKey)) + .plugin(qdrantPlugin) + .plugin(jetty) + .build(); + + String action = "qdrant/" + collection; + + Flow indexDocumentsFlow = + genkit.defineFlow( + "indexDocuments", + Void.class, + String.class, + (ctx, input) -> { + List documents = + SAMPLE_DOCUMENTS.stream().map(Document::fromText).collect(Collectors.toList()); + genkit.index(action, documents); + return "Successfully indexed " + documents.size() + " documents"; + }); + + @SuppressWarnings("unchecked") + Flow, Void> retrieveDocumentsFlow = + genkit.defineFlow( + "retrieveDocuments", + String.class, + (Class>) (Class) List.class, + (ctx, query) -> { + List docs = genkit.retrieve(action, query); + return docs.stream().map(Document::text).collect(Collectors.toList()); + }); + + Flow ragQueryFlow = + genkit.defineFlow( + "ragQuery", + String.class, + String.class, + (ctx, question) -> { + List docs = genkit.retrieve(action, question); + ModelResponse response = + genkit.generate( + GenerateOptions.builder() + .model("googleai/gemini-2.5-flash") + .system(RAG_SYSTEM_PROMPT) + .prompt(question) + .docs(docs) + .config(GenerationConfig.builder().temperature(0.3).build()) + .build()); + return response.getText(); + }); + + logger.info( + "Genkit Qdrant RAG Sample started. Flows: indexDocuments, retrieveDocuments, ragQuery"); + logger.info("Dev UI: http://localhost:4000 | Reflection: http://localhost:3100"); + + try { + jetty.start(); + } catch (Exception e) { + logger.error("Failed to start Jetty server", e); + System.exit(1); + } + } + + private static String getEnv(Dotenv dotenv, String name) { + String value = dotenv.get(name); + return (value != null && !value.isBlank()) ? value : System.getenv(name); + } + + private static String getEnvOrDefault(Dotenv dotenv, String name, String defaultValue) { + String value = getEnv(dotenv, name); + return (value != null && !value.isBlank()) ? value : defaultValue; + } +} diff --git a/samples/qdrant/src/main/resources/logback.xml b/samples/qdrant/src/main/resources/logback.xml new file mode 100644 index 000000000..dff2540b6 --- /dev/null +++ b/samples/qdrant/src/main/resources/logback.xml @@ -0,0 +1,20 @@ + + + + + %d{HH:mm:ss.SSS} %-5level %logger{24} - %msg%n + + + + + + + + + + + + + + + diff --git a/samples/weaviate/README.md b/samples/weaviate/README.md index c662a095b..676547a84 100644 --- a/samples/weaviate/README.md +++ b/samples/weaviate/README.md @@ -119,8 +119,8 @@ curl -X POST http://localhost:4000/api/flows/ragQuery \ ## Configuration The sample uses: -- **Embedder**: `googleai/text-embedding-004` (768 dimensions) -- **LLM**: `googleai/gemini-2.0-flash` +- **Embedder**: `googleai/gemini-embedding-001` (768 dimensions) +- **LLM**: `googleai/gemini-2.5-flash` - **Distance Metric**: Cosine similarity - **Collection Name**: `Films` diff --git a/samples/weaviate/src/main/java/com/google/genkit/samples/weaviate/WeaviateRAGSample.java b/samples/weaviate/src/main/java/com/google/genkit/samples/weaviate/WeaviateRAGSample.java index 6c1a89ac7..643af8c59 100644 --- a/samples/weaviate/src/main/java/com/google/genkit/samples/weaviate/WeaviateRAGSample.java +++ b/samples/weaviate/src/main/java/com/google/genkit/samples/weaviate/WeaviateRAGSample.java @@ -117,7 +117,7 @@ public static void main(String[] args) { weaviateBuilder.addCollection( WeaviateCollectionConfig.builder() .name("Films") - .embedderName("googleai/text-embedding-004") + .embedderName("googleai/gemini-embedding-001") .vectorDimension(768) .distanceMeasure(WeaviateCollectionConfig.DistanceMeasure.COSINE) .createCollectionIfMissing(true) @@ -192,7 +192,7 @@ public static void main(String[] args) { ModelResponse response = genkit.generate( GenerateOptions.builder() - .model("googleai/gemini-2.0-flash") + .model("googleai/gemini-2.5-flash") .system(RAG_SYSTEM_PROMPT) .prompt(question) .docs(docs) diff --git a/skills/building-ai-apps-with-genkit-java/SKILL.md b/skills/building-ai-apps-with-genkit-java/SKILL.md index a816471b9..637a7edf8 100644 --- a/skills/building-ai-apps-with-genkit-java/SKILL.md +++ b/skills/building-ai-apps-with-genkit-java/SKILL.md @@ -169,8 +169,8 @@ openai/text-embedding-3-small, openai/text-embedding-3-large openai/dall-e-3, openai/dall-e-2, openai/gpt-image-1 # Google Gemini -googleai/gemini-2.0-flash, googleai/gemini-1.5-pro, googleai/gemini-1.5-flash -googleai/text-embedding-004, googleai/imagen-3.0-generate-002 +googleai/gemini-2.5-flash, googleai/gemini-1.5-pro, googleai/gemini-1.5-flash +googleai/gemini-embedding-001, googleai/imagen-3.0-generate-002 # Anthropic anthropic/claude-sonnet-4-5-20250929, anthropic/claude-opus-4-5-20251101 @@ -801,7 +801,7 @@ Genkit genkit = Genkit.builder() .addRetriever(FirestoreRetrieverConfig.builder() .name("my-docs") .collection("documents") - .embedderName("googleai/text-embedding-004") + .embedderName("googleai/gemini-embedding-001") .vectorField("embedding") .contentField("text") .build()) @@ -827,7 +827,7 @@ public class MyFunction implements HttpFunction { genkit.defineFlow("myFlow", String.class, String.class, (ctx, input) -> genkit.generate( GenerateOptions.builder() - .model("googleai/gemini-2.0-flash") + .model("googleai/gemini-2.5-flash") .prompt(input) .build()).getText()); @@ -954,7 +954,7 @@ Genkit is provider-agnostic. Change the model string and plugin: // Switch from OpenAI to Gemini — just change plugin + model name .plugin(GoogleGenAIPlugin.create()) // ... -.model("googleai/gemini-2.0-flash") +.model("googleai/gemini-2.5-flash") // Switch to Anthropic .plugin(AnthropicPlugin.create()) @@ -987,7 +987,7 @@ genkit.defineFlow("deepAnalysis", String.class, String.class, genkit.defineFlow("creative", String.class, String.class, (ctx, q) -> genkit.generate(GenerateOptions.builder() - .model("googleai/gemini-2.0-flash").prompt(q).build()).getText()); + .model("googleai/gemini-2.5-flash").prompt(q).build()).getText()); ``` ### Custom OpenAI-Compatible Endpoint From 65a72774fdb006883b8550f5c69bcbb539e5139c Mon Sep 17 00:00:00 2001 From: xavidop Date: Mon, 6 Jul 2026 14:34:57 +0200 Subject: [PATCH 2/6] feat: milvus plugin --- .gitignore | 4 +- docs/astro.config.mjs | 1 + docs/src/content/docs/index.mdx | 1 + docs/src/content/docs/plugins/milvus.md | 72 ++++ plugins/milvus/pom.xml | 78 +++++ .../milvus/MilvusCollectionConfig.java | 235 +++++++++++++ .../genkit/plugins/milvus/MilvusPlugin.java | 175 ++++++++++ .../plugins/milvus/MilvusVectorStore.java | 321 ++++++++++++++++++ .../genkit/plugins/milvus/package-info.java | 25 ++ .../milvus/MilvusCollectionConfigTest.java | 75 ++++ .../plugins/milvus/MilvusPluginTest.java | 48 +++ .../plugins/milvus/MilvusVectorStoreTest.java | 118 +++++++ pom.xml | 2 + samples/README.md | 1 + samples/milvus/README.md | 61 ++++ samples/milvus/pom.xml | 86 +++++ samples/milvus/run.sh | 7 + .../samples/milvus/MilvusRAGSample.java | 165 +++++++++ samples/milvus/src/main/resources/logback.xml | 20 ++ samples/milvus/user.yaml | 1 + 20 files changed, 1495 insertions(+), 1 deletion(-) create mode 100644 docs/src/content/docs/plugins/milvus.md create mode 100644 plugins/milvus/pom.xml create mode 100644 plugins/milvus/src/main/java/com/google/genkit/plugins/milvus/MilvusCollectionConfig.java create mode 100644 plugins/milvus/src/main/java/com/google/genkit/plugins/milvus/MilvusPlugin.java create mode 100644 plugins/milvus/src/main/java/com/google/genkit/plugins/milvus/MilvusVectorStore.java create mode 100644 plugins/milvus/src/main/java/com/google/genkit/plugins/milvus/package-info.java create mode 100644 plugins/milvus/src/test/java/com/google/genkit/plugins/milvus/MilvusCollectionConfigTest.java create mode 100644 plugins/milvus/src/test/java/com/google/genkit/plugins/milvus/MilvusPluginTest.java create mode 100644 plugins/milvus/src/test/java/com/google/genkit/plugins/milvus/MilvusVectorStoreTest.java create mode 100644 samples/milvus/README.md create mode 100644 samples/milvus/pom.xml create mode 100755 samples/milvus/run.sh create mode 100644 samples/milvus/src/main/java/com/google/genkit/samples/milvus/MilvusRAGSample.java create mode 100644 samples/milvus/src/main/resources/logback.xml create mode 100644 samples/milvus/user.yaml diff --git a/.gitignore b/.gitignore index 13ca98480..eb627cbf3 100644 --- a/.gitignore +++ b/.gitignore @@ -31,4 +31,6 @@ node_modules hs_err_pid* samples/google-genai/generated_media/ .astro -.snapshots \ No newline at end of file +.snapshots + +samples/milvus/volumes \ No newline at end of file diff --git a/docs/astro.config.mjs b/docs/astro.config.mjs index 9bae49eff..8f511185a 100644 --- a/docs/astro.config.mjs +++ b/docs/astro.config.mjs @@ -144,6 +144,7 @@ export default defineConfig({ { label: "Pinecone", slug: "plugins/pinecone" }, { label: "Chroma", slug: "plugins/chroma" }, { label: "Qdrant", slug: "plugins/qdrant" }, + { label: "Milvus", slug: "plugins/milvus" }, { label: "MongoDB", slug: "plugins/mongodb" }, ], }, diff --git a/docs/src/content/docs/index.mdx b/docs/src/content/docs/index.mdx index 76d4f5336..6f4b1d721 100644 --- a/docs/src/content/docs/index.mdx +++ b/docs/src/content/docs/index.mdx @@ -270,6 +270,7 @@ String reply = chat.send("What is my name?").text(); // "Your name is Ada Lovela + diff --git a/docs/src/content/docs/plugins/milvus.md b/docs/src/content/docs/plugins/milvus.md new file mode 100644 index 000000000..09a97a892 --- /dev/null +++ b/docs/src/content/docs/plugins/milvus.md @@ -0,0 +1,72 @@ +--- +title: Milvus +description: Milvus vector database integration for Genkit RAG workflows. +--- + +The Milvus plugin registers retrievers and indexers backed by a [Milvus](https://milvus.io/) server (v2 REST API) for Retrieval-Augmented Generation. + +## Installation + +```xml + + com.google.genkit + genkit-plugin-milvus + 1.0.0-SNAPSHOT + +``` + +## Requirements + +- A running Milvus server. For local development: + ```bash + curl -sfL https://raw.githubusercontent.com/milvus-io/milvus/master/scripts/standalone_embed.sh -o standalone_embed.sh + bash standalone_embed.sh start + ``` +- Java 21+ +- An embedder (e.g. from the Google GenAI plugin) + +## Usage + +`MilvusPlugin` registers a retriever and indexer named `milvus/` for each configured collection. + +```java +import com.google.genkit.plugins.milvus.MilvusCollectionConfig; +import com.google.genkit.plugins.milvus.MilvusPlugin; + +Genkit genkit = Genkit.builder() + .plugin(GoogleGenAIPlugin.create(apiKey)) + .plugin( + MilvusPlugin.builder() + .url("http://localhost:19530") // default; serves both gRPC and REST + .token(System.getenv("MILVUS_TOKEN")) // optional; required for auth-enabled servers / Zilliz Cloud + .addCollection( + MilvusCollectionConfig.builder() + .collectionName("films") + .embedderName("googleai/gemini-embedding-001") + .metric(MilvusCollectionConfig.Metric.COSINE) + .createCollectionIfNotExists(true) // default + .build()) + .build()) + .build(); + +// Index and retrieve +genkit.index("milvus/films", documents); +List results = genkit.retrieve("milvus/films", "a Christopher Nolan sci-fi film"); +``` + +## Configuration + +Tune per-collection settings with `MilvusCollectionConfig`: + +- `collectionName` — the Milvus collection name (required) +- `embedderName` — the embedder used to vectorize documents and queries (required) +- `dimension` — the embedding dimension used when creating the collection (default `768`). The store also probes the embedder on first use and creates the collection with the model's actual output dimension, so this is only a fallback. +- `metric` — `COSINE` (default), `L2`, or `INNER_PRODUCT` +- `createCollectionIfNotExists` — auto-create the collection (default `true`) +- `addAdditionalMetadata(key, value)` — metadata merged into every indexed document + +Collections are created in Milvus "quick setup" mode (auto id primary key, a `vector` field, and dynamic fields). The document text is stored under `text` and its metadata as a JSON string under `metadata`. + +## Sample + +See the [milvus sample](https://github.com/genkit-ai/genkit-java/tree/main/samples/milvus). diff --git a/plugins/milvus/pom.xml b/plugins/milvus/pom.xml new file mode 100644 index 000000000..5ee3c1f6c --- /dev/null +++ b/plugins/milvus/pom.xml @@ -0,0 +1,78 @@ + + + + 4.0.0 + + + com.google.genkit + genkit-parent + 1.0.0-SNAPSHOT + ../../pom.xml + + + genkit-plugin-milvus + jar + Genkit Milvus Plugin + Milvus vector database integration for Genkit - indexer and retriever for RAG workflows + + + false + + + + + + com.google.genkit + genkit-core + ${project.version} + + + com.google.genkit + genkit-ai + ${project.version} + + + + + com.fasterxml.jackson.core + jackson-databind + + + + + org.slf4j + slf4j-api + + + + + org.junit.jupiter + junit-jupiter + test + + + org.mockito + mockito-core + test + + + diff --git a/plugins/milvus/src/main/java/com/google/genkit/plugins/milvus/MilvusCollectionConfig.java b/plugins/milvus/src/main/java/com/google/genkit/plugins/milvus/MilvusCollectionConfig.java new file mode 100644 index 000000000..2f6468c85 --- /dev/null +++ b/plugins/milvus/src/main/java/com/google/genkit/plugins/milvus/MilvusCollectionConfig.java @@ -0,0 +1,235 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed 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. + * + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.google.genkit.plugins.milvus; + +import java.util.HashMap; +import java.util.Map; + +/** + * Configuration for a single Milvus collection managed by {@link MilvusPlugin}. + * + *

Each config registers a retriever and indexer named {@code milvus/}. The + * collection is created in Milvus "quick setup" mode (auto id primary key, a {@code vector} field, + * and dynamic fields) storing the document text under {@code text} and its metadata as a JSON + * string under {@code metadata}. + */ +public final class MilvusCollectionConfig { + + /** Vector similarity metric used by the Milvus index. */ + public enum Metric { + COSINE("COSINE"), + L2("L2"), + INNER_PRODUCT("IP"); + + private final String value; + + Metric(String value) { + this.value = value; + } + + /** + * Returns the Milvus metric type name. + * + * @return the metric type name + */ + public String getValue() { + return value; + } + } + + private final String collectionName; + private final String embedderName; + private final int dimension; + private final Metric metric; + private final boolean createCollectionIfNotExists; + private final Map additionalMetadata; + + private MilvusCollectionConfig(Builder builder) { + this.collectionName = builder.collectionName; + this.embedderName = builder.embedderName; + this.dimension = builder.dimension; + this.metric = builder.metric; + this.createCollectionIfNotExists = builder.createCollectionIfNotExists; + this.additionalMetadata = new HashMap<>(builder.additionalMetadata); + } + + /** + * Returns the Milvus collection name. + * + * @return the collection name + */ + public String getCollectionName() { + return collectionName; + } + + /** + * Returns the name of the embedder used to vectorize documents and queries. + * + * @return the embedder name + */ + public String getEmbedderName() { + return embedderName; + } + + /** + * Returns the embedding dimension (default {@code 768}). + * + * @return the embedding dimension + */ + public int getDimension() { + return dimension; + } + + /** + * Returns the vector similarity metric (default {@link Metric#COSINE}). + * + * @return the metric + */ + public Metric getMetric() { + return metric; + } + + /** + * Returns whether to create the collection on first use if it does not exist (default {@code + * true}). + * + * @return {@code true} if the collection should be created when missing + */ + public boolean isCreateCollectionIfNotExists() { + return createCollectionIfNotExists; + } + + /** + * Returns additional metadata merged into every indexed document. + * + * @return the additional metadata + */ + public Map getAdditionalMetadata() { + return additionalMetadata; + } + + /** + * Creates a new builder. + * + * @return a new builder + */ + public static Builder builder() { + return new Builder(); + } + + /** Builder for {@link MilvusCollectionConfig}. */ + public static final class Builder { + private String collectionName; + private String embedderName; + private int dimension = 768; + private Metric metric = Metric.COSINE; + private boolean createCollectionIfNotExists = true; + private final Map additionalMetadata = new HashMap<>(); + + private Builder() {} + + /** + * Sets the collection name. + * + * @param collectionName the collection name + * @return this builder + */ + public Builder collectionName(String collectionName) { + this.collectionName = collectionName; + return this; + } + + /** + * Sets the embedder name. + * + * @param embedderName the embedder name + * @return this builder + */ + public Builder embedderName(String embedderName) { + this.embedderName = embedderName; + return this; + } + + /** + * Sets the embedding dimension. + * + * @param dimension the embedding dimension (must be {@code >= 1}) + * @return this builder + */ + public Builder dimension(int dimension) { + this.dimension = dimension; + return this; + } + + /** + * Sets the vector similarity metric. + * + * @param metric the metric + * @return this builder + */ + public Builder metric(Metric metric) { + this.metric = metric; + return this; + } + + /** + * Sets whether to create the collection on first use if it does not exist. + * + * @param createCollectionIfNotExists whether to create the collection when missing + * @return this builder + */ + public Builder createCollectionIfNotExists(boolean createCollectionIfNotExists) { + this.createCollectionIfNotExists = createCollectionIfNotExists; + return this; + } + + /** + * Adds a metadata entry merged into every indexed document. + * + * @param key the metadata key + * @param value the metadata value + * @return this builder + */ + public Builder addAdditionalMetadata(String key, Object value) { + this.additionalMetadata.put(key, value); + return this; + } + + /** + * Builds a new {@code MilvusCollectionConfig}. + * + * @return a new config instance + */ + public MilvusCollectionConfig build() { + if (collectionName == null || collectionName.isBlank()) { + throw new IllegalArgumentException("collectionName must be non-empty"); + } + if (embedderName == null || embedderName.isBlank()) { + throw new IllegalArgumentException("embedderName must be non-empty"); + } + if (dimension < 1) { + throw new IllegalArgumentException("dimension must be >= 1"); + } + if (metric == null) { + throw new IllegalArgumentException("metric must be non-null"); + } + return new MilvusCollectionConfig(this); + } + } +} diff --git a/plugins/milvus/src/main/java/com/google/genkit/plugins/milvus/MilvusPlugin.java b/plugins/milvus/src/main/java/com/google/genkit/plugins/milvus/MilvusPlugin.java new file mode 100644 index 000000000..f14aa3bb8 --- /dev/null +++ b/plugins/milvus/src/main/java/com/google/genkit/plugins/milvus/MilvusPlugin.java @@ -0,0 +1,175 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed 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. + * + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.google.genkit.plugins.milvus; + +import com.google.genkit.ai.Embedder; +import com.google.genkit.core.Action; +import com.google.genkit.core.ActionType; +import com.google.genkit.core.Plugin; +import com.google.genkit.core.Registry; +import java.util.ArrayList; +import java.util.List; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Milvus vector database plugin for Genkit. + * + *

Registers a retriever and indexer named {@code milvus/} for each configured + * collection, talking to a Milvus server over its v2 REST API. + * + *

Example usage: + * + *

{@code
+ * Genkit genkit = Genkit.builder()
+ *     .plugin(GoogleGenAIPlugin.create(apiKey))
+ *     .plugin(
+ *         MilvusPlugin.builder()
+ *             .url("http://localhost:19530")
+ *             .addCollection(
+ *                 MilvusCollectionConfig.builder()
+ *                     .collectionName("films")
+ *                     .embedderName("googleai/gemini-embedding-001")
+ *                     .build())
+ *             .build())
+ *     .build();
+ * }
+ */ +public final class MilvusPlugin implements Plugin { + + /** The plugin name; used as the {@code milvus/...} action prefix. */ + public static final String PLUGIN_NAME = "milvus"; + + /** Default Milvus server URL. */ + public static final String DEFAULT_URL = "http://localhost:19530"; + + private static final Logger logger = LoggerFactory.getLogger(MilvusPlugin.class); + + private final String url; + private final String token; + private final List collectionConfigs; + + private MilvusPlugin(Builder builder) { + this.url = builder.url; + this.token = builder.token; + this.collectionConfigs = new ArrayList<>(builder.collectionConfigs); + } + + /** + * Creates a new builder. + * + * @return a new builder + */ + public static Builder builder() { + return new Builder(); + } + + @Override + public String getName() { + return PLUGIN_NAME; + } + + @Override + public List> init() { + throw new IllegalStateException( + "MilvusPlugin requires a Registry to resolve embedders. Use init(registry) instead."); + } + + @Override + public List> init(Registry registry) { + List> actions = new ArrayList<>(); + for (MilvusCollectionConfig config : collectionConfigs) { + String embedderKey = ActionType.EMBEDDER.keyFromName(config.getEmbedderName()); + Action embedderAction = registry.lookupAction(embedderKey); + if (embedderAction == null) { + throw new IllegalStateException( + "Embedder not found: " + + config.getEmbedderName() + + ". Make sure the embedder plugin is registered before MilvusPlugin."); + } + if (!(embedderAction instanceof Embedder embedder)) { + throw new IllegalStateException( + "Action " + config.getEmbedderName() + " is not an Embedder"); + } + + MilvusVectorStore store = new MilvusVectorStore(url, token, config, embedder); + actions.add(store.createRetriever()); + actions.add(store.createIndexer()); + logger.info("Registered Milvus vector store: {}/{}", PLUGIN_NAME, config.getCollectionName()); + } + return actions; + } + + /** Builder for {@link MilvusPlugin}. */ + public static final class Builder { + private String url = DEFAULT_URL; + private String token; + private final List collectionConfigs = new ArrayList<>(); + + private Builder() {} + + /** + * Sets the Milvus server URL (default {@value #DEFAULT_URL}). + * + * @param url the server URL + * @return this builder + */ + public Builder url(String url) { + this.url = url; + return this; + } + + /** + * Sets the Milvus auth token (optional; required for Zilliz Cloud or auth-enabled servers). + * + * @param token the auth token + * @return this builder + */ + public Builder token(String token) { + this.token = token; + return this; + } + + /** + * Adds a collection configuration. + * + * @param config the collection configuration + * @return this builder + */ + public Builder addCollection(MilvusCollectionConfig config) { + this.collectionConfigs.add(config); + return this; + } + + /** + * Builds the plugin. + * + * @return a new {@code MilvusPlugin} + */ + public MilvusPlugin build() { + if (url == null || url.isBlank()) { + throw new IllegalStateException("url is required"); + } + if (collectionConfigs.isEmpty()) { + throw new IllegalStateException("At least one collection configuration is required"); + } + return new MilvusPlugin(this); + } + } +} diff --git a/plugins/milvus/src/main/java/com/google/genkit/plugins/milvus/MilvusVectorStore.java b/plugins/milvus/src/main/java/com/google/genkit/plugins/milvus/MilvusVectorStore.java new file mode 100644 index 000000000..222cba462 --- /dev/null +++ b/plugins/milvus/src/main/java/com/google/genkit/plugins/milvus/MilvusVectorStore.java @@ -0,0 +1,321 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed 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. + * + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.google.genkit.plugins.milvus; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.google.genkit.ai.Document; +import com.google.genkit.ai.EmbedRequest; +import com.google.genkit.ai.EmbedResponse; +import com.google.genkit.ai.Embedder; +import com.google.genkit.ai.Indexer; +import com.google.genkit.ai.IndexerRequest; +import com.google.genkit.ai.IndexerResponse; +import com.google.genkit.ai.Retriever; +import com.google.genkit.ai.RetrieverRequest; +import com.google.genkit.ai.RetrieverResponse; +import com.google.genkit.core.ActionContext; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Milvus vector store backed by the Milvus v2 REST API. + * + *

Indexes documents into a Milvus collection (quick-setup mode: auto id, a {@code vector} field, + * dynamic {@code text} and {@code metadata} fields) and retrieves the nearest neighbors of a query + * embedding. + */ +public final class MilvusVectorStore { + + private static final Logger logger = LoggerFactory.getLogger(MilvusVectorStore.class); + private static final ObjectMapper MAPPER = new ObjectMapper(); + private static final String TEXT_FIELD = "text"; + private static final String METADATA_FIELD = "metadata"; + private static final String VECTOR_FIELD = "vector"; + + private final HttpClient http = HttpClient.newHttpClient(); + private final String baseUrl; + private final String token; + private final MilvusCollectionConfig config; + private final Embedder embedder; + + private volatile boolean initialized = false; + + /** + * Creates a new store. + * + * @param baseUrl the Milvus server base URL (e.g. {@code http://localhost:19530}) + * @param token the Milvus auth token, or {@code null} when the server requires none + * @param config the collection configuration + * @param embedder the embedder used to vectorize documents and queries + */ + public MilvusVectorStore( + String baseUrl, String token, MilvusCollectionConfig config, Embedder embedder) { + this.baseUrl = baseUrl.endsWith("/") ? baseUrl.substring(0, baseUrl.length() - 1) : baseUrl; + this.token = token; + this.config = config; + this.embedder = embedder; + } + + /** Creates the retriever action registered by the plugin. */ + Retriever createRetriever() { + String name = MilvusPlugin.PLUGIN_NAME + "/" + config.getCollectionName(); + return Retriever.builder().name(name).handler(this::retrieve).build(); + } + + /** Creates the indexer action registered by the plugin. */ + Indexer createIndexer() { + String name = MilvusPlugin.PLUGIN_NAME + "/" + config.getCollectionName(); + return Indexer.builder().name(name).handler(this::index).build(); + } + + private synchronized void ensureInitialized() { + if (initialized) { + return; + } + if (config.isCreateCollectionIfNotExists() && !collectionExists()) { + ObjectNode body = MAPPER.createObjectNode(); + body.put("collectionName", config.getCollectionName()); + body.put("dimension", resolveDimension()); + body.put("metricType", config.getMetric().getValue()); + // Let Milvus generate the Int64 primary key so callers don't have to supply one. + body.put("autoID", true); + send("/v2/vectordb/collections/create", body); + logger.info("Created Milvus collection {}", config.getCollectionName()); + } + initialized = true; + } + + private boolean collectionExists() { + ObjectNode body = MAPPER.createObjectNode(); + body.put("collectionName", config.getCollectionName()); + JsonNode data = send("/v2/vectordb/collections/has", body).get("data"); + return data != null && data.has("has") && data.get("has").asBoolean(); + } + + private int resolveDimension() { + try { + return generateEmbedding(null, "genkit dimension probe").size(); + } catch (RuntimeException e) { + logger.debug( + "Embedding probe failed; using configured dimension {}: {}", + config.getDimension(), + e.getMessage()); + return config.getDimension(); + } + } + + /** + * Retrieves documents similar to the query. + * + * @param context the action context + * @param request the retriever request + * @return the retriever response with matching documents + */ + public RetrieverResponse retrieve(ActionContext context, RetrieverRequest request) { + ensureInitialized(); + Document queryDoc = request.getQuery(); + if (queryDoc == null || queryDoc.text() == null || queryDoc.text().isBlank()) { + throw new RuntimeException("Query document has no text content"); + } + int topK = + request.getOptions() != null && request.getOptions().getK() != null + ? request.getOptions().getK() + : 10; + List queryEmbedding = generateEmbedding(context, queryDoc.text()); + + ObjectNode body = MAPPER.createObjectNode(); + body.put("collectionName", config.getCollectionName()); + body.put("annsField", VECTOR_FIELD); + body.put("limit", topK); + ArrayNode data = body.putArray("data"); + data.add(floatsToArray(queryEmbedding)); + ArrayNode outputFields = body.putArray("outputFields"); + outputFields.add(TEXT_FIELD); + outputFields.add(METADATA_FIELD); + + JsonNode resp = send("/v2/vectordb/entities/search", body); + List documents = new ArrayList<>(); + JsonNode results = resp.get("data"); + if (results != null && results.isArray()) { + for (JsonNode hit : results) { + documents.add(toDocument(hit)); + } + } + logger.debug( + "Retrieved {} documents from collection {}", documents.size(), config.getCollectionName()); + return new RetrieverResponse(documents); + } + + /** + * Indexes documents into the collection, generating an embedding for each. + * + * @param context the action context + * @param request the indexer request + * @return the indexer response + */ + public IndexerResponse index(ActionContext context, IndexerRequest request) { + ensureInitialized(); + List documents = request.getDocuments(); + if (documents == null || documents.isEmpty()) { + logger.warn("No documents to index"); + return new IndexerResponse(); + } + + ObjectNode body = MAPPER.createObjectNode(); + body.put("collectionName", config.getCollectionName()); + ArrayNode data = body.putArray("data"); + for (Document doc : documents) { + String content = doc.text() != null ? doc.text() : ""; + List embedding = generateEmbedding(context, content); + + ObjectNode entity = data.addObject(); + entity.set(VECTOR_FIELD, floatsToArray(embedding)); + entity.put(TEXT_FIELD, content); + + Map metadata = new HashMap<>(); + if (doc.getMetadata() != null) { + for (Map.Entry entry : doc.getMetadata().entrySet()) { + if (!"id".equals(entry.getKey())) { + metadata.put(entry.getKey(), entry.getValue()); + } + } + } + metadata.putAll(config.getAdditionalMetadata()); + entity.put(METADATA_FIELD, writeJson(metadata)); + } + + send("/v2/vectordb/entities/insert", body); + logger.info( + "Indexed {} documents into collection {}", documents.size(), config.getCollectionName()); + return new IndexerResponse(); + } + + private Document toDocument(JsonNode hit) { + Map metadata = new HashMap<>(); + String content = ""; + JsonNode textNode = hit.get(TEXT_FIELD); + if (textNode != null && !textNode.isNull()) { + content = textNode.asText(); + } + JsonNode metadataNode = hit.get(METADATA_FIELD); + if (metadataNode != null && !metadataNode.isNull()) { + try { + String raw = metadataNode.isTextual() ? metadataNode.asText() : metadataNode.toString(); + if (raw != null && !raw.isBlank()) { + @SuppressWarnings("unchecked") + Map parsed = MAPPER.readValue(raw, Map.class); + metadata.putAll(parsed); + } + } catch (Exception e) { + logger.debug("Failed to parse Milvus metadata: {}", e.getMessage()); + } + } + if (hit.get("id") != null) { + metadata.put("id", hit.get("id").asText()); + } + if (hit.get("distance") != null && hit.get("distance").isNumber()) { + double distance = hit.get("distance").asDouble(); + metadata.put("distance", distance); + metadata.put("score", distance); + } + Document doc = new Document(content); + doc.setMetadata(metadata); + return doc; + } + + private ArrayNode floatsToArray(List values) { + ArrayNode arr = MAPPER.createArrayNode(); + for (float v : values) { + arr.add(v); + } + return arr; + } + + private List generateEmbedding(ActionContext ctx, String text) { + EmbedResponse response = embedder.run(ctx, new EmbedRequest(List.of(new Document(text)))); + if (response.getEmbeddings() == null || response.getEmbeddings().isEmpty()) { + throw new RuntimeException("Failed to generate embedding for text"); + } + float[] values = response.getEmbeddings().get(0).getValues(); + List out = new ArrayList<>(values.length); + for (float v : values) { + out.add(v); + } + return out; + } + + private static String writeJson(Object value) { + try { + return MAPPER.writeValueAsString(value); + } catch (Exception e) { + throw new RuntimeException("Failed to serialize metadata: " + e.getMessage(), e); + } + } + + /** Posts a JSON request to the Milvus REST API and validates both the HTTP and Milvus codes. */ + private JsonNode send(String path, JsonNode body) { + try { + HttpRequest.Builder builder = + HttpRequest.newBuilder() + .uri(URI.create(baseUrl + path)) + .header("Content-Type", "application/json") + .header("Accept", "application/json"); + if (token != null && !token.isBlank()) { + builder.header("Authorization", "Bearer " + token); + } + builder.POST( + HttpRequest.BodyPublishers.ofString( + MAPPER.writeValueAsString(body), StandardCharsets.UTF_8)); + HttpResponse response = + http.send(builder.build(), HttpResponse.BodyHandlers.ofString()); + if (response.statusCode() / 100 != 2) { + throw new RuntimeException( + "Milvus request " + + path + + " failed (" + + response.statusCode() + + "): " + + response.body()); + } + JsonNode node = MAPPER.readTree(response.body()); + JsonNode code = node.get("code"); + if (code != null && code.asInt() != 0) { + throw new RuntimeException( + "Milvus request " + path + " failed (code " + code.asInt() + "): " + node.toString()); + } + return node; + } catch (RuntimeException e) { + throw e; + } catch (Exception e) { + throw new RuntimeException("Milvus request " + path + " failed: " + e.getMessage(), e); + } + } +} diff --git a/plugins/milvus/src/main/java/com/google/genkit/plugins/milvus/package-info.java b/plugins/milvus/src/main/java/com/google/genkit/plugins/milvus/package-info.java new file mode 100644 index 000000000..7f39cda73 --- /dev/null +++ b/plugins/milvus/src/main/java/com/google/genkit/plugins/milvus/package-info.java @@ -0,0 +1,25 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed 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. + * + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Milvus vector database integration for Genkit. + * + *

{@link com.google.genkit.plugins.milvus.MilvusPlugin} registers retrievers and indexers backed + * by a Milvus server (v2 REST API) for RAG workflows. + */ +package com.google.genkit.plugins.milvus; diff --git a/plugins/milvus/src/test/java/com/google/genkit/plugins/milvus/MilvusCollectionConfigTest.java b/plugins/milvus/src/test/java/com/google/genkit/plugins/milvus/MilvusCollectionConfigTest.java new file mode 100644 index 000000000..b78cfe69d --- /dev/null +++ b/plugins/milvus/src/test/java/com/google/genkit/plugins/milvus/MilvusCollectionConfigTest.java @@ -0,0 +1,75 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed 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. + * + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.google.genkit.plugins.milvus; + +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 org.junit.jupiter.api.Test; + +/** Unit tests for {@link MilvusCollectionConfig}. */ +class MilvusCollectionConfigTest { + + @Test + void defaultsAreSane() { + MilvusCollectionConfig c = + MilvusCollectionConfig.builder().collectionName("films").embedderName("e").build(); + assertEquals("films", c.getCollectionName()); + assertEquals(768, c.getDimension()); + assertEquals(MilvusCollectionConfig.Metric.COSINE, c.getMetric()); + assertEquals("COSINE", c.getMetric().getValue()); + assertTrue(c.isCreateCollectionIfNotExists()); + } + + @Test + void customBuilder() { + MilvusCollectionConfig c = + MilvusCollectionConfig.builder() + .collectionName("docs") + .embedderName("e") + .dimension(1536) + .metric(MilvusCollectionConfig.Metric.INNER_PRODUCT) + .createCollectionIfNotExists(false) + .addAdditionalMetadata("source", "wiki") + .build(); + assertEquals(1536, c.getDimension()); + assertEquals("IP", c.getMetric().getValue()); + assertEquals(false, c.isCreateCollectionIfNotExists()); + assertEquals("wiki", c.getAdditionalMetadata().get("source")); + } + + @Test + void builderValidates() { + assertThrows( + IllegalArgumentException.class, + () -> MilvusCollectionConfig.builder().embedderName("e").build()); + assertThrows( + IllegalArgumentException.class, + () -> MilvusCollectionConfig.builder().collectionName("c").build()); + assertThrows( + IllegalArgumentException.class, + () -> + MilvusCollectionConfig.builder() + .collectionName("c") + .embedderName("e") + .dimension(0) + .build()); + } +} diff --git a/plugins/milvus/src/test/java/com/google/genkit/plugins/milvus/MilvusPluginTest.java b/plugins/milvus/src/test/java/com/google/genkit/plugins/milvus/MilvusPluginTest.java new file mode 100644 index 000000000..a165d488d --- /dev/null +++ b/plugins/milvus/src/test/java/com/google/genkit/plugins/milvus/MilvusPluginTest.java @@ -0,0 +1,48 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed 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. + * + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.google.genkit.plugins.milvus; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import org.junit.jupiter.api.Test; + +/** Unit tests for {@link MilvusPlugin}. */ +class MilvusPluginTest { + + private static MilvusCollectionConfig config() { + return MilvusCollectionConfig.builder().collectionName("films").embedderName("e").build(); + } + + @Test + void getName() { + assertEquals("milvus", MilvusPlugin.builder().addCollection(config()).build().getName()); + } + + @Test + void requiresAtLeastOneCollection() { + assertThrows(IllegalStateException.class, () -> MilvusPlugin.builder().build()); + } + + @Test + void initWithoutRegistryThrows() { + MilvusPlugin plugin = MilvusPlugin.builder().addCollection(config()).build(); + assertThrows(IllegalStateException.class, plugin::init); + } +} diff --git a/plugins/milvus/src/test/java/com/google/genkit/plugins/milvus/MilvusVectorStoreTest.java b/plugins/milvus/src/test/java/com/google/genkit/plugins/milvus/MilvusVectorStoreTest.java new file mode 100644 index 000000000..317383194 --- /dev/null +++ b/plugins/milvus/src/test/java/com/google/genkit/plugins/milvus/MilvusVectorStoreTest.java @@ -0,0 +1,118 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed 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. + * + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.google.genkit.plugins.milvus; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assumptions.assumeTrue; + +import com.google.genkit.ai.Document; +import com.google.genkit.ai.EmbedResponse; +import com.google.genkit.ai.Embedder; +import com.google.genkit.ai.EmbedderInfo; +import com.google.genkit.ai.IndexerRequest; +import com.google.genkit.ai.RetrieverRequest; +import com.google.genkit.ai.RetrieverResponse; +import java.util.ArrayList; +import java.util.List; +import java.util.Random; +import java.util.UUID; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +/** + * Integration tests for {@link MilvusVectorStore}, gated on the {@code MILVUS_URL} environment + * variable (e.g. a Milvus server started with the standalone Docker script). Skipped via {@link + * org.junit.jupiter.api.Assumptions} when unset. Uses a deterministic stub embedder so a query + * equal to an indexed document's text retrieves that document first. + */ +class MilvusVectorStoreTest { + + private static final String URL = System.getenv("MILVUS_URL"); + private static final String TOKEN = System.getenv("MILVUS_TOKEN"); + private static final int DIM = 16; + + private MilvusVectorStore store; + + private static boolean configured() { + return URL != null && !URL.isEmpty(); + } + + private static Embedder stubEmbedder() { + return new Embedder( + "test/stub", + new EmbedderInfo(), + (ctx, req) -> { + List out = new ArrayList<>(); + for (Document doc : req.getDocuments()) { + Random random = new Random(doc.text().hashCode()); + float[] values = new float[DIM]; + for (int i = 0; i < DIM; i++) { + values[i] = random.nextFloat(); + } + out.add(new EmbedResponse.Embedding(values)); + } + return new EmbedResponse(out); + }); + } + + @BeforeEach + void setUp() { + if (!configured()) { + return; + } + MilvusCollectionConfig config = + MilvusCollectionConfig.builder() + .collectionName("genkit_test_" + UUID.randomUUID().toString().replace("-", "")) + .embedderName("test/stub") + .dimension(DIM) + .build(); + store = new MilvusVectorStore(URL, TOKEN, config, stubEmbedder()); + } + + @Test + void indexThenRetrieveReturnsNearestFirst() throws Exception { + assumeTrue(configured()); + List docs = + List.of( + Document.fromText("The Matrix is a sci-fi film about simulated reality."), + Document.fromText("The Godfather is a crime film about a mafia family."), + Document.fromText("Inception is a sci-fi film about dreams.")); + store.index(null, new IndexerRequest(docs)); + + RetrieverRequest request = new RetrieverRequest(Document.fromText(docs.get(1).text())); + RetrieverRequest.RetrieverOptions options = new RetrieverRequest.RetrieverOptions(); + options.setK(3); + request.setOptions(options); + + // Newly inserted data may take a moment to become searchable. + RetrieverResponse response = null; + for (int attempt = 0; attempt < 15; attempt++) { + response = store.retrieve(null, request); + if (!response.getDocuments().isEmpty()) { + break; + } + Thread.sleep(1000); + } + assertFalse(response.getDocuments().isEmpty()); + assertEquals( + "The Godfather is a crime film about a mafia family.", + response.getDocuments().get(0).text()); + } +} diff --git a/pom.xml b/pom.xml index e9a79e558..69fa99f2d 100644 --- a/pom.xml +++ b/pom.xml @@ -90,6 +90,7 @@ plugins/mongodb plugins/chroma plugins/qdrant + plugins/milvus plugins/pinecone plugins/evaluators plugins/aws-bedrock @@ -128,6 +129,7 @@ samples/pinecone samples/chroma samples/qdrant + samples/milvus samples/mongo-vector samples/structured-output samples/aws-bedrock diff --git a/samples/README.md b/samples/README.md index 0e910b6ef..b14386858 100644 --- a/samples/README.md +++ b/samples/README.md @@ -79,6 +79,7 @@ The Dev UI will be available at `http://localhost:4000` and allows you to: | [pinecone](./pinecone) | Pinecone vector database RAG sample | `OPENAI_API_KEY` + `PINECONE_API_KEY` | | [chroma](./chroma) | Chroma vector database RAG sample | `GEMINI_API_KEY` + Chroma | | [qdrant](./qdrant) | Qdrant vector database RAG sample | `GEMINI_API_KEY` + Qdrant | +| [milvus](./milvus) | Milvus vector database RAG sample | `GEMINI_API_KEY` + Milvus | | [mongo-vector](./mongo-vector) | MongoDB Atlas Vector Search RAG sample | `GEMINI_API_KEY` + MongoDB Atlas | | [agents-human-in-the-loop](./agents-human-in-the-loop) | Agent interrupts + human-in-the-loop resume | `GEMINI_API_KEY` | | [agents-firestore-session](./agents-firestore-session) | Agent session persistence backed by Firestore | `GEMINI_API_KEY` + Firestore | diff --git a/samples/milvus/README.md b/samples/milvus/README.md new file mode 100644 index 000000000..87384c7d2 --- /dev/null +++ b/samples/milvus/README.md @@ -0,0 +1,61 @@ +# Milvus RAG Sample + +A Retrieval-Augmented Generation sample that indexes film descriptions into **Milvus** via `MilvusPlugin` and answers questions with a **Gemini** model. + +## Prerequisites + +- Java 21+ and Maven 3.6+ +- A `GEMINI_API_KEY` — get one from [Google AI Studio](https://aistudio.google.com/apikey) +- A reachable Milvus server (see the Docker command below) + +## Run Milvus locally with Docker + +Milvus ships a helper script that runs a single standalone container (with embedded etcd + local storage): + +```bash +curl -sfL https://raw.githubusercontent.com/milvus-io/milvus/master/scripts/standalone_embed.sh -o standalone_embed.sh +bash standalone_embed.sh start + +# wait until it's accepting connections (health probe on 9091) +until curl -sf http://localhost:9091/healthz >/dev/null 2>&1; do sleep 1; done +echo "milvus ready" +``` + +Milvus serves both gRPC and the REST API on port `19530`. The collection is created automatically on first use. Stop and remove it later with: + +```bash +bash standalone_embed.sh stop && bash standalone_embed.sh delete +``` + +## Configure + +```bash +export GEMINI_API_KEY= +export MILVUS_URL=http://localhost:19530 # optional (default) +export MILVUS_TOKEN= # optional (required for auth-enabled servers / Zilliz Cloud) +export MILVUS_COLLECTION=genkit_films # optional (default) +``` + +## Run + +```bash +mvn -q exec:java +``` + +Then open the Genkit Dev UI at http://localhost:4000 (or run under `genkit start -- mvn -q exec:java`) and exercise the flows: + +- `indexDocuments` — index the sample film descriptions +- `retrieveDocuments` — return the films matching a query +- `ragQuery` — answer a question using retrieved context + +Or via curl: + +```bash +curl -X POST http://localhost:4000/api/flows/indexDocuments -H 'Content-Type: application/json' -d '{}' +curl -X POST http://localhost:4000/api/flows/ragQuery -H 'Content-Type: application/json' \ + -d '{"data": "What Christopher Nolan films are mentioned?"}' +``` + +## Configuration + +`MilvusPlugin` talks to the Milvus v2 REST API. Tune per-collection settings with `MilvusCollectionConfig` (collection name, embedder, dimension, metric — `COSINE`/`L2`/`INNER_PRODUCT`, `createCollectionIfNotExists`, additional metadata). Collections are created in quick-setup mode; the document text is stored under `text` and metadata as a JSON string under `metadata`. diff --git a/samples/milvus/pom.xml b/samples/milvus/pom.xml new file mode 100644 index 000000000..d840da867 --- /dev/null +++ b/samples/milvus/pom.xml @@ -0,0 +1,86 @@ + + + + 4.0.0 + + + com.google.genkit + genkit-parent + 1.0.0-SNAPSHOT + ../../pom.xml + + + genkit-sample-milvus + jar + Genkit Milvus RAG Sample + Sample application demonstrating Milvus vector search with Genkit + + + true + com.google.genkit.samples.milvus.MilvusRAGSample + + + + + com.google.genkit + genkit + ${project.version} + + + com.google.genkit + genkit-plugin-milvus + ${project.version} + + + com.google.genkit + genkit-plugin-google-genai + ${project.version} + + + com.google.genkit + genkit-plugin-jetty + ${project.version} + + + ch.qos.logback + logback-classic + + + io.github.cdimascio + dotenv-java + 3.2.0 + + + + + + + org.codehaus.mojo + exec-maven-plugin + 3.6.3 + + ${exec.mainClass} + + + + + diff --git a/samples/milvus/run.sh b/samples/milvus/run.sh new file mode 100755 index 000000000..d10fb3e2a --- /dev/null +++ b/samples/milvus/run.sh @@ -0,0 +1,7 @@ +#!/bin/bash + +# Genkit Sample Runner +# This script runs the sample application + +echo "Building and running the sample..." +mvn clean compile exec:java diff --git a/samples/milvus/src/main/java/com/google/genkit/samples/milvus/MilvusRAGSample.java b/samples/milvus/src/main/java/com/google/genkit/samples/milvus/MilvusRAGSample.java new file mode 100644 index 000000000..4843a19e9 --- /dev/null +++ b/samples/milvus/src/main/java/com/google/genkit/samples/milvus/MilvusRAGSample.java @@ -0,0 +1,165 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed 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. + * + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.google.genkit.samples.milvus; + +import com.google.genkit.Genkit; +import com.google.genkit.GenkitOptions; +import com.google.genkit.ai.*; +import com.google.genkit.core.Flow; +import com.google.genkit.plugins.googlegenai.GoogleGenAIPlugin; +import com.google.genkit.plugins.jetty.JettyPlugin; +import com.google.genkit.plugins.jetty.JettyPluginOptions; +import com.google.genkit.plugins.milvus.MilvusCollectionConfig; +import com.google.genkit.plugins.milvus.MilvusPlugin; +import io.github.cdimascio.dotenv.Dotenv; +import java.util.List; +import java.util.stream.Collectors; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Sample application demonstrating Milvus vector search with Genkit. + * + *

Indexes a handful of film descriptions into a Milvus collection, retrieves the nearest matches + * for a query, and answers questions with a RAG flow. Requires a running Milvus server (see the + * README for a one-line Docker command) and a {@code GEMINI_API_KEY}. + */ +public class MilvusRAGSample { + + private static final Logger logger = LoggerFactory.getLogger(MilvusRAGSample.class); + + private static final List SAMPLE_DOCUMENTS = + List.of( + "The Godfather is a 1972 crime film directed by Francis Ford Coppola about the Corleone crime family.", + "The Dark Knight is a 2008 superhero film directed by Christopher Nolan featuring Batman against the Joker.", + "Pulp Fiction is a 1994 crime film directed by Quentin Tarantino known for its nonlinear narrative.", + "Inception is a 2010 sci-fi film directed by Christopher Nolan about dream infiltration.", + "The Matrix is a 1999 sci-fi film directed by the Wachowskis exploring simulated reality.", + "Forrest Gump is a 1994 drama directed by Robert Zemeckis about a man's extraordinary life.", + "Star Wars is a 1977 sci-fi film directed by George Lucas set in a galaxy far, far away.", + "The Shawshank Redemption is a 1994 drama about hope and friendship in a prison."); + + private static final String RAG_SYSTEM_PROMPT = + """ + You are a helpful assistant that answers questions based on the provided context documents. + Answer only from the context. If the context is insufficient, say so. + """; + + public static void main(String[] args) { + Dotenv dotenv = Dotenv.configure().ignoreIfMissing().systemProperties().load(); + + String geminiApiKey = getEnv(dotenv, "GEMINI_API_KEY"); + if (geminiApiKey == null) { + logger.error("Please set GEMINI_API_KEY in .env file or environment variable"); + System.exit(1); + } + + String milvusUrl = getEnvOrDefault(dotenv, "MILVUS_URL", "http://localhost:19530"); + String milvusToken = getEnv(dotenv, "MILVUS_TOKEN"); // optional + String collection = getEnvOrDefault(dotenv, "MILVUS_COLLECTION", "genkit_films"); + + logger.info("Starting Milvus RAG Sample (url={}, collection={})", milvusUrl, collection); + + MilvusPlugin milvusPlugin = + MilvusPlugin.builder() + .url(milvusUrl) + .token(milvusToken) + .addCollection( + MilvusCollectionConfig.builder() + .collectionName(collection) + .embedderName("googleai/gemini-embedding-001") + .metric(MilvusCollectionConfig.Metric.COSINE) + .build()) + .build(); + + JettyPlugin jetty = new JettyPlugin(JettyPluginOptions.builder().port(8088).build()); + Genkit genkit = + Genkit.builder() + .options(GenkitOptions.builder().devMode(true).reflectionPort(3100).build()) + .plugin(GoogleGenAIPlugin.create(geminiApiKey)) + .plugin(milvusPlugin) + .plugin(jetty) + .build(); + + String action = "milvus/" + collection; + + Flow indexDocumentsFlow = + genkit.defineFlow( + "indexDocuments", + Void.class, + String.class, + (ctx, input) -> { + List documents = + SAMPLE_DOCUMENTS.stream().map(Document::fromText).collect(Collectors.toList()); + genkit.index(action, documents); + return "Successfully indexed " + documents.size() + " documents"; + }); + + @SuppressWarnings("unchecked") + Flow, Void> retrieveDocumentsFlow = + genkit.defineFlow( + "retrieveDocuments", + String.class, + (Class>) (Class) List.class, + (ctx, query) -> { + List docs = genkit.retrieve(action, query); + return docs.stream().map(Document::text).collect(Collectors.toList()); + }); + + Flow ragQueryFlow = + genkit.defineFlow( + "ragQuery", + String.class, + String.class, + (ctx, question) -> { + List docs = genkit.retrieve(action, question); + ModelResponse response = + genkit.generate( + GenerateOptions.builder() + .model("googleai/gemini-2.5-flash") + .system(RAG_SYSTEM_PROMPT) + .prompt(question) + .docs(docs) + .config(GenerationConfig.builder().temperature(0.3).build()) + .build()); + return response.getText(); + }); + + logger.info( + "Genkit Milvus RAG Sample started. Flows: indexDocuments, retrieveDocuments, ragQuery"); + logger.info("Dev UI: http://localhost:4000 | Reflection: http://localhost:3100"); + + try { + jetty.start(); + } catch (Exception e) { + logger.error("Failed to start Jetty server", e); + System.exit(1); + } + } + + private static String getEnv(Dotenv dotenv, String name) { + String value = dotenv.get(name); + return (value != null && !value.isBlank()) ? value : System.getenv(name); + } + + private static String getEnvOrDefault(Dotenv dotenv, String name, String defaultValue) { + String value = getEnv(dotenv, name); + return (value != null && !value.isBlank()) ? value : defaultValue; + } +} diff --git a/samples/milvus/src/main/resources/logback.xml b/samples/milvus/src/main/resources/logback.xml new file mode 100644 index 000000000..dff2540b6 --- /dev/null +++ b/samples/milvus/src/main/resources/logback.xml @@ -0,0 +1,20 @@ + + + + + %d{HH:mm:ss.SSS} %-5level %logger{24} - %msg%n + + + + + + + + + + + + + + + diff --git a/samples/milvus/user.yaml b/samples/milvus/user.yaml new file mode 100644 index 000000000..8d312694b --- /dev/null +++ b/samples/milvus/user.yaml @@ -0,0 +1 @@ +# Extra config to override default milvus.yaml From a0fee7851124959e9a41d905b7f4fd2b3bdc06ec Mon Sep 17 00:00:00 2001 From: xavidop Date: Mon, 6 Jul 2026 14:37:46 +0200 Subject: [PATCH 3/6] fix: remove user.yaml --- samples/milvus/user.yaml | 1 - 1 file changed, 1 deletion(-) delete mode 100644 samples/milvus/user.yaml diff --git a/samples/milvus/user.yaml b/samples/milvus/user.yaml deleted file mode 100644 index 8d312694b..000000000 --- a/samples/milvus/user.yaml +++ /dev/null @@ -1 +0,0 @@ -# Extra config to override default milvus.yaml From e99012c29278dea922c8ff46afb170ae4ff935ff Mon Sep 17 00:00:00 2001 From: xavidop Date: Mon, 6 Jul 2026 18:59:15 +0200 Subject: [PATCH 4/6] feat: update of the plugins --- docs/src/content/docs/plugins/anthropic.md | 21 +- docs/src/content/docs/plugins/aws-bedrock.md | 13 + docs/src/content/docs/plugins/cohere.md | 17 +- docs/src/content/docs/plugins/deepseek.md | 8 +- docs/src/content/docs/plugins/google-genai.md | 41 +- docs/src/content/docs/plugins/groq.md | 4 +- docs/src/content/docs/plugins/mistral.md | 23 +- docs/src/content/docs/plugins/openai.md | 10 +- docs/src/content/docs/plugins/xai.md | 11 +- plugins/anthropic/README.md | 14 +- .../plugins/anthropic/AnthropicModel.java | 10 +- .../plugins/anthropic/AnthropicPlugin.java | 19 +- .../anthropic/AnthropicPluginTest.java | 3 +- plugins/aws-bedrock/README.md | 19 +- .../awsbedrock/AwsBedrockEmbedder.java | 172 ++++++++ .../plugins/awsbedrock/AwsBedrockModel.java | 50 +-- .../plugins/awsbedrock/AwsBedrockPlugin.java | 56 ++- .../plugins/awsbedrock/AwsBedrockSigner.java | 109 +++++ .../awsbedrock/AwsBedrockPluginTest.java | 18 + plugins/azure-foundry/README.md | 20 +- .../azurefoundry/AzureFoundryPlugin.java | 43 +- plugins/cohere/README.md | 10 + .../genkit/plugins/cohere/CoherePlugin.java | 44 +- .../plugins/cohere/CoherePluginTest.java | 20 + .../plugins/compatoai/CompatOAIEmbedder.java | 178 ++++++++ .../plugins/deepseek/DeepSeekPlugin.java | 8 +- .../plugins/googlegenai/GeminiEmbedder.java | 2 +- .../googlegenai/GoogleGenAIPlugin.java | 83 ++-- .../plugins/googlegenai/ImagenModel.java | 2 +- .../genkit/plugins/googlegenai/OmniModel.java | 397 ++++++++++++++++++ .../genkit/plugins/googlegenai/TtsModel.java | 34 +- .../genkit/plugins/googlegenai/VeoModel.java | 10 +- .../googlegenai/GoogleGenAIPluginTest.java | 21 + .../plugins/googlegenai/TtsModelTest.java | 52 +++ plugins/groq/README.md | 5 +- .../genkit/plugins/groq/GroqPlugin.java | 7 +- plugins/mistral/README.md | 23 +- .../genkit/plugins/mistral/MistralPlugin.java | 44 +- .../plugins/mistral/MistralPluginTest.java | 20 + plugins/openai/README.md | 11 +- .../genkit/plugins/openai/OpenAIPlugin.java | 26 +- .../plugins/openai/OpenAIPluginTest.java | 2 +- plugins/xai/README.md | 11 +- .../google/genkit/plugins/xai/XAIPlugin.java | 27 +- .../genkit/plugins/xai/XAIPluginTest.java | 7 +- samples/anthropic/README.md | 14 +- .../genkit/samples/AzureFoundrySample.java | 6 +- .../samples/firebase/FirestoreRAGSample.java | 2 +- samples/google-genai/README.md | 8 +- .../google/genkit/samples/GoogleGenAIApp.java | 91 +++- samples/groq/README.md | 9 +- .../com/google/genkit/samples/GroqSample.java | 12 +- samples/mistral/README.md | 3 - .../google/genkit/samples/MistralSample.java | 2 +- samples/pinecone/README.md | 2 +- samples/xai/README.md | 35 +- .../com/google/genkit/samples/XAISample.java | 12 +- 57 files changed, 1627 insertions(+), 294 deletions(-) create mode 100644 plugins/aws-bedrock/src/main/java/com/google/genkit/plugins/awsbedrock/AwsBedrockEmbedder.java create mode 100644 plugins/aws-bedrock/src/main/java/com/google/genkit/plugins/awsbedrock/AwsBedrockSigner.java create mode 100644 plugins/compat-oai/src/main/java/com/google/genkit/plugins/compatoai/CompatOAIEmbedder.java create mode 100644 plugins/google-genai/src/main/java/com/google/genkit/plugins/googlegenai/OmniModel.java create mode 100644 plugins/google-genai/src/test/java/com/google/genkit/plugins/googlegenai/TtsModelTest.java diff --git a/docs/src/content/docs/plugins/anthropic.md b/docs/src/content/docs/plugins/anthropic.md index d7ce87b87..34dbdb67b 100644 --- a/docs/src/content/docs/plugins/anthropic.md +++ b/docs/src/content/docs/plugins/anthropic.md @@ -32,25 +32,26 @@ Genkit genkit = Genkit.builder() ModelResponse response = genkit.generate( GenerateOptions.builder() - .model("anthropic/claude-sonnet-4-5-20250929") + .model("anthropic/claude-sonnet-5") .prompt("Tell me about AI") .build()); ``` ## Available models -### Claude 4.5 family +### Claude 5 family +- `anthropic/claude-fable-5` +- `anthropic/claude-sonnet-5` + +### Claude 4 family +- `anthropic/claude-opus-4-8` +- `anthropic/claude-opus-4-7` +- `anthropic/claude-opus-4-6` +- `anthropic/claude-sonnet-4-6` - `anthropic/claude-opus-4-5-20251101` - `anthropic/claude-sonnet-4-5-20250929` - `anthropic/claude-haiku-4-5-20251001` - -### Claude 4 family -- `anthropic/claude-4-*` - -### Claude 3 family -- `anthropic/claude-3-opus-*` -- `anthropic/claude-3-sonnet-*` -- `anthropic/claude-3-haiku-*` +- `anthropic/claude-opus-4-1-20250805` ## Features diff --git a/docs/src/content/docs/plugins/aws-bedrock.md b/docs/src/content/docs/plugins/aws-bedrock.md index b13d93b61..9dd4a8f5f 100644 --- a/docs/src/content/docs/plugins/aws-bedrock.md +++ b/docs/src/content/docs/plugins/aws-bedrock.md @@ -51,6 +51,19 @@ ModelResponse response = genkit.generate( - **OpenAI** — Via Bedrock marketplace - And many more... +## Embeddings + +Bedrock embedding models (Amazon Titan and Cohere Embed) are available via the `InvokeModel` API: + +- `aws-bedrock/amazon.titan-embed-text-v2:0` +- `aws-bedrock/cohere.embed-english-v3` +- `aws-bedrock/cohere.embed-multilingual-v3` + +```java +EmbedResponse response = + genkit.embed("aws-bedrock/amazon.titan-embed-text-v2:0", documents); +``` + ## Features - Multi-provider model access diff --git a/docs/src/content/docs/plugins/cohere.md b/docs/src/content/docs/plugins/cohere.md index 00f222be1..8a62e786a 100644 --- a/docs/src/content/docs/plugins/cohere.md +++ b/docs/src/content/docs/plugins/cohere.md @@ -37,14 +37,29 @@ ModelResponse response = genkit.generate( ## Available models +- `cohere/command-a-plus-05-2026` — flagship (Mixture-of-Experts, text + vision) +- `cohere/command-a-reasoning-08-2025` +- `cohere/command-a-vision-07-2025` - `cohere/command-a-03-2025` - `cohere/command-r7b-12-2024` - `cohere/command-r-08-2024` - `cohere/command-r-plus-08-2024` +## Embeddings + +Cohere embedding models are available through the OpenAI-compatible endpoint: + +- `cohere/embed-v4.0` +- `cohere/embed-multilingual-v3.0` +- `cohere/embed-english-v3.0` + +```java +EmbedResponse response = genkit.embed("cohere/embed-v4.0", documents); +``` + ## Features -- Text generation, tool calling, RAG support, SSE streaming +- Text generation, tool calling, RAG support, SSE streaming, embeddings ## Sample diff --git a/docs/src/content/docs/plugins/deepseek.md b/docs/src/content/docs/plugins/deepseek.md index a19a0b247..133f268c5 100644 --- a/docs/src/content/docs/plugins/deepseek.md +++ b/docs/src/content/docs/plugins/deepseek.md @@ -30,15 +30,17 @@ Genkit genkit = Genkit.builder() ModelResponse response = genkit.generate( GenerateOptions.builder() - .model("deepseek/deepseek-chat") + .model("deepseek/deepseek-v4-pro") .prompt("Tell me about AI") .build()); ``` ## Available models -- `deepseek/deepseek-chat` — General chat model -- `deepseek/deepseek-reasoner` — Reasoning model +- `deepseek/deepseek-v4-pro` — Flagship V4 model +- `deepseek/deepseek-v4-flash` — Fast, economical V4 model +- `deepseek/deepseek-chat` — Legacy alias (deprecated, retires 2026-07-24) +- `deepseek/deepseek-reasoner` — Legacy reasoning alias (deprecated, retires 2026-07-24) ## Features diff --git a/docs/src/content/docs/plugins/google-genai.md b/docs/src/content/docs/plugins/google-genai.md index f3a5224f3..28cd23823 100644 --- a/docs/src/content/docs/plugins/google-genai.md +++ b/docs/src/content/docs/plugins/google-genai.md @@ -85,7 +85,7 @@ Map embedOptions = Map.of( ## Text-to-Speech (TTS) -Generate natural-sounding speech from text using Gemini TTS models: +Generate natural-sounding speech from text using Gemini TTS models (`gemini-3.1-flash-tts-preview`, `gemini-2.5-flash-preview-tts`, `gemini-2.5-pro-preview-tts`): ```java Map ttsOptions = Map.of("voiceName", "Zephyr"); @@ -105,6 +105,12 @@ String audioDataUrl = response.getMessage().getParts().get(0).getMedia().getUrl( // "data:audio/wav;base64,..." ``` +The plugin automatically frames your prompt with a synthesis preamble so the TTS model treats it as a transcript to voice. Without this, TTS models reject "vague" prompts with a `400` error (`Model tried to generate text, but it should only be used for TTS`). To supply your own framing (e.g. `"Say cheerfully: ..."`), set a `ttsInstruction` custom option — a preamble string, or an empty string to send the prompt verbatim: + +```java +Map ttsOptions = Map.of("voiceName", "Zephyr", "ttsInstruction", ""); +``` + ### Saving audio to a file ```java @@ -131,7 +137,7 @@ GenerationConfig config = GenerationConfig.builder() ModelResponse response = genkit.generate( GenerateOptions.builder() - .model("googleai/veo-3.0-generate-001") + .model("googleai/veo-3.1-generate-preview") .prompt("A serene Japanese garden with cherry blossoms falling") .config(config) .build()); @@ -167,6 +173,37 @@ ModelResponse response = genkit.generate( ``` +## Video generation and editing (Gemini Omni) + +Gemini Omni (`googleai/gemini-omni-flash-preview`) generates and iteratively edits video through the Gemini Interactions API. It supports **conversational editing** — each turn can build on the previous result while preserving elements you did not mention. + +```java +// First turn — generate a video +ModelResponse first = genkit.generate( + GenerateOptions.builder() + .model("googleai/gemini-omni-flash-preview") + .prompt("A marble rolling down a chain-reaction track") + .build()); + +// The returned interaction id lets you continue editing +String interactionId = (String) first.getCustom().get("interactionId"); + +// Follow-up turn — edit the previous result +ModelResponse edited = genkit.generate( + GenerateOptions.builder() + .model("googleai/gemini-omni-flash-preview") + .prompt("Brighten the background and add a slow push-in on the logo") + .config(GenerationConfig.builder() + .custom(Map.of("previousInteractionId", interactionId)) + .build()) + .build()); + +// The generated video is returned as a media part (data URL by default) +String videoUrl = edited.getMessage().getParts().get(0).getMedia().getUrl(); +``` + +Config options: `previousInteractionId` (continue/edit a prior interaction), `aspectRatio` (e.g. `"16:9"`), `duration` (e.g. `"10s"`), `delivery` (`"inline"` (default, base64) | `"uri"`), `task` (`"text_to_video"` | `"image_to_video"`), `thinkingLevel`, and `maxOutputTokens`. Only the Gemini Developer API (API key) is supported — not Vertex AI. The Interactions API is in preview. + ## Sample See the [google-genai sample](https://github.com/genkit-ai/genkit-java/tree/main/samples/google-genai) for complete examples of text generation, tool calling, embeddings, image generation, TTS, and video generation. diff --git a/docs/src/content/docs/plugins/groq.md b/docs/src/content/docs/plugins/groq.md index 8088038f2..101963643 100644 --- a/docs/src/content/docs/plugins/groq.md +++ b/docs/src/content/docs/plugins/groq.md @@ -41,8 +41,8 @@ ModelResponse response = genkit.generate( |-------|-------| | `groq/llama-3.1-8b-instant` | ~1200 tokens/sec | | `groq/llama-3.3-70b-versatile` | ~560 tokens/sec | -| `groq/openai/gpt-oss` models | Varies | -| `groq/meta-llama/llama-guard-4` | Content moderation | +| `groq/openai/gpt-oss-120b`, `groq/openai/gpt-oss-20b` | Varies | +| `groq/compound`, `groq/compound-mini` | Agentic systems (web search + code execution) | ## Features diff --git a/docs/src/content/docs/plugins/mistral.md b/docs/src/content/docs/plugins/mistral.md index 79ca024fa..df1f03485 100644 --- a/docs/src/content/docs/plugins/mistral.md +++ b/docs/src/content/docs/plugins/mistral.md @@ -30,7 +30,7 @@ Genkit genkit = Genkit.builder() ModelResponse response = genkit.generate( GenerateOptions.builder() - .model("mistral/mistral-large-3-25-12") + .model("mistral/mistral-large-2512") .prompt("Tell me about AI") .build()); ``` @@ -39,15 +39,24 @@ ModelResponse response = genkit.generate( | Model | Context | |-------|---------| -| `mistral/mistral-large-3-25-12` | 128K | -| `mistral/mistral-medium-3-1-25-08` | 128K | -| `mistral/mistral-small-*` | 128K | -| `mistral/ministral-*` | 128K | -| `mistral/codestral-25-08` | 256K | +| `mistral/mistral-large-2512` | 128K | +| `mistral/mistral-medium-2604` | 128K | +| `mistral/mistral-small-2603` | 128K | +| `mistral/ministral-3b-2512`, `mistral/ministral-8b-2512`, `mistral/ministral-14b-2512` | 128K | +| `mistral/codestral-2508` | 256K | + +## Embeddings + +- `mistral/mistral-embed` +- `mistral/codestral-embed` + +```java +EmbedResponse response = genkit.embed("mistral/mistral-embed", documents); +``` ## Features -- Text generation, streaming, tool calling, RAG +- Text generation, streaming, tool calling, RAG, embeddings ## Sample diff --git a/docs/src/content/docs/plugins/openai.md b/docs/src/content/docs/plugins/openai.md index 3c82ecd17..531471987 100644 --- a/docs/src/content/docs/plugins/openai.md +++ b/docs/src/content/docs/plugins/openai.md @@ -39,11 +39,13 @@ ModelResponse response = genkit.generate( ## Available models -- `openai/gpt-4o` — Most capable model +- `openai/gpt-5.5` — Most capable flagship (also `gpt-5.5-pro`) +- `openai/gpt-5.4` — High-performance general model (also `-mini`, `-nano`, `-pro`) +- `openai/gpt-5` — GPT-5 base (also `gpt-5-mini`, `gpt-5-nano`, `gpt-5-pro`) +- `openai/gpt-4.1` — Smartest non-reasoning model (also `gpt-4.1-mini`) +- `openai/gpt-4o` — Multimodal model - `openai/gpt-4o-mini` — Fast and cost-effective -- `openai/gpt-4-turbo` — Previous generation flagship -- `openai/o1-preview` — Reasoning model -- `openai/o1-mini` — Fast reasoning model +- `openai/o3` — Reasoning model (also `o3-pro`) ## Embeddings diff --git a/docs/src/content/docs/plugins/xai.md b/docs/src/content/docs/plugins/xai.md index 440202d3d..ee2e4cc3c 100644 --- a/docs/src/content/docs/plugins/xai.md +++ b/docs/src/content/docs/plugins/xai.md @@ -32,7 +32,7 @@ Genkit genkit = Genkit.builder() ModelResponse response = genkit.generate( GenerateOptions.builder() - .model("xai/grok-3") + .model("xai/grok-4.3") .prompt("Tell me about AI") .build()); ``` @@ -41,10 +41,11 @@ ModelResponse response = genkit.generate( | Model | Context Window | |-------|---------------| -| `xai/grok-4` | Up to 2M tokens | -| `xai/grok-4-1-fast` | 131K tokens | -| `xai/grok-3` | 131K tokens | -| `xai/grok-3-mini` | 131K tokens | +| `xai/grok-4.3` | Up to 2M tokens | +| `xai/grok-4.20-0309-reasoning` | Up to 2M tokens | +| `xai/grok-4.20-0309-non-reasoning` | Up to 2M tokens | +| `xai/grok-4.20-multi-agent-0309` | Up to 2M tokens | +| `xai/grok-build-0.1` | 256K tokens (agentic coding) | ## Features diff --git a/plugins/anthropic/README.md b/plugins/anthropic/README.md index 047f9e42a..b4527090a 100644 --- a/plugins/anthropic/README.md +++ b/plugins/anthropic/README.md @@ -79,17 +79,19 @@ AnthropicPlugin plugin = new AnthropicPlugin( - `anthropic/claude-sonnet-4-5-20250929` - Balanced performance (recommended) - `anthropic/claude-haiku-4-5-20251001` - Fast and efficient +### Claude 5 Family +- `anthropic/claude-fable-5` - Most capable widely released model +- `anthropic/claude-sonnet-5` - Balanced speed and intelligence + ### Claude 4 Family +- `anthropic/claude-opus-4-8` - Flagship Opus +- `anthropic/claude-opus-4-7` - Claude Opus 4.7 +- `anthropic/claude-opus-4-6` - Claude Opus 4.6 +- `anthropic/claude-sonnet-4-6` - Claude Sonnet 4.6 - `anthropic/claude-opus-4-1-20250805` - Claude Opus 4.1 - `anthropic/claude-opus-4-20250514` - Claude Opus 4 - `anthropic/claude-sonnet-4-20250514` - Claude Sonnet 4 -### Claude 3 Family -- `anthropic/claude-3-7-sonnet-20250219` - Claude Sonnet 3.7 -- `anthropic/claude-3-5-haiku-20241022` - Claude Haiku 3.5 -- `anthropic/claude-3-opus-20240229` - Claude Opus 3 -- `anthropic/claude-3-haiku-20240307` - Claude Haiku 3 - ## Using Custom Models If you need to use a model not in the default list (e.g., a newer model release), register it using `customModel()`: diff --git a/plugins/anthropic/src/main/java/com/google/genkit/plugins/anthropic/AnthropicModel.java b/plugins/anthropic/src/main/java/com/google/genkit/plugins/anthropic/AnthropicModel.java index aa61fb677..f727514f9 100644 --- a/plugins/anthropic/src/main/java/com/google/genkit/plugins/anthropic/AnthropicModel.java +++ b/plugins/anthropic/src/main/java/com/google/genkit/plugins/anthropic/AnthropicModel.java @@ -39,8 +39,8 @@ /** * Anthropic Claude model implementation for Genkit. * - *

Supports Claude 3.5, Claude 3, and Claude 2 model families with both synchronous and streaming - * generation. + *

Supports the Claude 5, Claude 4, and Claude 3.5 model families with both synchronous and + * streaming generation. */ public class AnthropicModel implements Model { @@ -78,9 +78,9 @@ private ModelInfo createModelInfo() { ModelInfo.ModelCapabilities caps = new ModelInfo.ModelCapabilities(); caps.setMultiturn(true); - // Claude 3+ models support vision - caps.setMedia(modelName.contains("claude-3")); - caps.setTools(modelName.contains("claude-3")); + // All supported Claude models (3.5, 4.x, and 5 families) support vision and tool use. + caps.setMedia(true); + caps.setTools(true); caps.setSystemRole(true); caps.setOutput(Set.of("text", "json")); info.setSupports(caps); diff --git a/plugins/anthropic/src/main/java/com/google/genkit/plugins/anthropic/AnthropicPlugin.java b/plugins/anthropic/src/main/java/com/google/genkit/plugins/anthropic/AnthropicPlugin.java index 44d20f027..bbfcc0bfe 100644 --- a/plugins/anthropic/src/main/java/com/google/genkit/plugins/anthropic/AnthropicPlugin.java +++ b/plugins/anthropic/src/main/java/com/google/genkit/plugins/anthropic/AnthropicPlugin.java @@ -29,8 +29,8 @@ /** * AnthropicPlugin provides Anthropic Claude model integrations for Genkit. * - *

This plugin registers Claude models (Claude 3.5, Claude 3, Claude 2) as Genkit actions for - * text generation with support for streaming. + *

This plugin registers Claude models (Claude 5, Claude 4, and Claude 3.5 families) as Genkit + * actions for text generation with support for streaming. */ public class AnthropicPlugin implements Plugin { @@ -39,6 +39,14 @@ public class AnthropicPlugin implements Plugin { /** Supported Claude models. */ public static final List SUPPORTED_MODELS = Arrays.asList( + // Claude 5 family + "claude-fable-5", + "claude-sonnet-5", + // Claude 4.8 / 4.7 / 4.6 + "claude-opus-4-8", + "claude-opus-4-7", + "claude-opus-4-6", + "claude-sonnet-4-6", // Claude 4.5 family "claude-opus-4-5-20251101", "claude-sonnet-4-5-20250929", @@ -46,12 +54,7 @@ public class AnthropicPlugin implements Plugin { // Claude 4.x family "claude-opus-4-1-20250805", "claude-opus-4-20250514", - "claude-sonnet-4-20250514", - // Claude 3.x family - "claude-3-7-sonnet-20250219", - "claude-3-5-haiku-20241022", - "claude-3-opus-20240229", - "claude-3-haiku-20240307"); + "claude-sonnet-4-20250514"); private final AnthropicPluginOptions options; private final List customModels = new ArrayList<>(); diff --git a/plugins/anthropic/src/test/java/com/google/genkit/plugins/anthropic/AnthropicPluginTest.java b/plugins/anthropic/src/test/java/com/google/genkit/plugins/anthropic/AnthropicPluginTest.java index fde246912..39aab3804 100644 --- a/plugins/anthropic/src/test/java/com/google/genkit/plugins/anthropic/AnthropicPluginTest.java +++ b/plugins/anthropic/src/test/java/com/google/genkit/plugins/anthropic/AnthropicPluginTest.java @@ -82,7 +82,8 @@ void testSupportedModels() { assertNotNull(AnthropicPlugin.SUPPORTED_MODELS); assertTrue(AnthropicPlugin.SUPPORTED_MODELS.contains("claude-opus-4-5-20251101")); assertTrue(AnthropicPlugin.SUPPORTED_MODELS.contains("claude-sonnet-4-5-20250929")); - assertTrue(AnthropicPlugin.SUPPORTED_MODELS.contains("claude-3-opus-20240229")); + assertTrue(AnthropicPlugin.SUPPORTED_MODELS.contains("claude-opus-4-8")); + assertTrue(AnthropicPlugin.SUPPORTED_MODELS.contains("claude-sonnet-5")); } @Test diff --git a/plugins/aws-bedrock/README.md b/plugins/aws-bedrock/README.md index 07cc80451..6334aeb42 100644 --- a/plugins/aws-bedrock/README.md +++ b/plugins/aws-bedrock/README.md @@ -41,17 +41,16 @@ System.out.println(response.getText()); ### Amazon Models (ON_DEMAND) - **Nova**: `amazon.nova-pro-v1:0`, `amazon.nova-lite-v1:0`, `amazon.nova-micro-v1:0`, `amazon.nova-premier-v1:0`, `amazon.nova-2-lite-v1:0`, `amazon.nova-sonic-v1:0`, `amazon.nova-2-sonic-v1:0` -- **Titan**: `amazon.titan-tg1-large`, `amazon.titan-text-express-v1` ### Anthropic Models (INFERENCE_PROFILE required for Claude 4.x and 3.5+) +- **Claude 5 / 4.6+**: `anthropic.claude-sonnet-5`, `anthropic.claude-fable-5`, `anthropic.claude-opus-4-8`, `anthropic.claude-opus-4-7`, `anthropic.claude-opus-4-6-v1`, `anthropic.claude-sonnet-4-6` - **Claude 4**: `anthropic.claude-sonnet-4-20250514-v1:0`, `anthropic.claude-sonnet-4-5-20250929-v1:0`, `anthropic.claude-haiku-4-5-20251001-v1:0`, `anthropic.claude-opus-4-1-20250805-v1:0`, `anthropic.claude-opus-4-5-20251101-v1:0` - **Claude 3.x**: `anthropic.claude-3-7-sonnet-20250219-v1:0`, `anthropic.claude-3-5-sonnet-20241022-v2:0`, `anthropic.claude-3-5-sonnet-20240620-v1:0`, `anthropic.claude-3-5-haiku-20241022-v1:0` -- **Claude 3** (ON_DEMAND): `anthropic.claude-3-opus-20240229-v1:0`, `anthropic.claude-3-sonnet-20240229-v1:0`, `anthropic.claude-3-haiku-20240307-v1:0` +- **Claude 3** (ON_DEMAND): `anthropic.claude-3-sonnet-20240229-v1:0`, `anthropic.claude-3-haiku-20240307-v1:0` ### Meta Llama Models (INFERENCE_PROFILE) - **Llama 4**: `meta.llama4-scout-17b-instruct-v1:0`, `meta.llama4-maverick-17b-instruct-v1:0` - **Llama 3.3**: `meta.llama3-3-70b-instruct-v1:0` -- **Llama 3.2**: `meta.llama3-2-90b-instruct-v1:0`, `meta.llama3-2-11b-instruct-v1:0`, `meta.llama3-2-3b-instruct-v1:0`, `meta.llama3-2-1b-instruct-v1:0` - **Llama 3.1**: `meta.llama3-1-70b-instruct-v1:0`, `meta.llama3-1-8b-instruct-v1:0` - **Llama 3** (ON_DEMAND): `meta.llama3-70b-instruct-v1:0`, `meta.llama3-8b-instruct-v1:0` @@ -60,11 +59,12 @@ System.out.println(response.getText()); - **Mistral Large** (ON_DEMAND): `mistral.mistral-large-3-675b-instruct`, `mistral.mistral-large-2402-v1:0` - **Mistral Medium**: `mistral.magistral-small-2509`, `mistral.mistral-small-2402-v1:0` - **Ministral**: `mistral.ministral-3-14b-instruct`, `mistral.ministral-3-8b-instruct`, `mistral.ministral-3-3b-instruct` +- **Devstral** (INFERENCE_PROFILE): `mistral.devstral-2-123b` - **Mixtral**: `mistral.mixtral-8x7b-instruct-v0:1`, `mistral.mistral-7b-instruct-v0:2` - **Voxtral**: `mistral.voxtral-mini-3b-2507`, `mistral.voxtral-small-24b-2507` ### Other Providers (ON_DEMAND) -- **DeepSeek**: `deepseek.r1-v1:0` (INFERENCE_PROFILE) +- **DeepSeek**: `deepseek.v3.2`, `deepseek.v3-v1:0`, `deepseek.r1-v1:0` (INFERENCE_PROFILE) - **Cohere**: `cohere.command-r-plus-v1:0`, `cohere.command-r-v1:0` - **AI21 Labs**: `ai21.jamba-1-5-large-v1:0`, `ai21.jamba-1-5-mini-v1:0` - **Google Gemma**: `google.gemma-3-27b-it`, `google.gemma-3-12b-it`, `google.gemma-3-4b-it` @@ -72,10 +72,17 @@ System.out.println(response.getText()); - **NVIDIA Nemotron**: `nvidia.nemotron-nano-12b-v2`, `nvidia.nemotron-nano-9b-v2`, `nvidia.nemotron-nano-3-30b` - **OpenAI**: `openai.gpt-oss-120b-1:0`, `openai.gpt-oss-20b-1:0`, `openai.gpt-oss-safeguard-120b`, `openai.gpt-oss-safeguard-20b` - **Writer** (INFERENCE_PROFILE): `writer.palmyra-x5-v1:0`, `writer.palmyra-x4-v1:0` -- **MiniMax**: `minimax.minimax-m2` -- **Moonshot**: `moonshot.kimi-k2-thinking` +- **MiniMax**: `minimax.minimax-m2.5`, `minimax.minimax-m2.1`, `minimax.minimax-m2` +- **Moonshot**: `moonshotai.kimi-k2.5`, `moonshot.kimi-k2-thinking` +- **Z.AI**: `zai.glm-5`, `zai.glm-4.7`, `zai.glm-4.7-flash` - **TwelveLabs**: `twelvelabs.pegasus-1-2-v1:0` +### Embedding Models (InvokeModel) +- **Amazon Titan**: `amazon.titan-embed-text-v2:0` +- **Cohere Embed**: `cohere.embed-english-v3`, `cohere.embed-multilingual-v3` + +Register additional embedding models with `customEmbeddingModel(...)`. + **Note**: Models marked with "INFERENCE_PROFILE" require using an AWS Bedrock inference profile ARN instead of the model ID directly. See [AWS Bedrock Inference Profiles](https://docs.aws.amazon.com/bedrock/latest/userguide/cross-region-inference.html) for details. ## Configuration diff --git a/plugins/aws-bedrock/src/main/java/com/google/genkit/plugins/awsbedrock/AwsBedrockEmbedder.java b/plugins/aws-bedrock/src/main/java/com/google/genkit/plugins/awsbedrock/AwsBedrockEmbedder.java new file mode 100644 index 000000000..4b884f637 --- /dev/null +++ b/plugins/aws-bedrock/src/main/java/com/google/genkit/plugins/awsbedrock/AwsBedrockEmbedder.java @@ -0,0 +1,172 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed 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. + * + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.google.genkit.plugins.awsbedrock; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.google.genkit.ai.Document; +import com.google.genkit.ai.EmbedRequest; +import com.google.genkit.ai.EmbedResponse; +import com.google.genkit.ai.Embedder; +import com.google.genkit.ai.EmbedderInfo; +import com.google.genkit.core.ActionContext; +import com.google.genkit.core.GenkitException; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.TimeUnit; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.Response; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * AWS Bedrock embedder implementation for Genkit. + * + *

Uses the AWS Bedrock {@code InvokeModel} API (via HTTP with AWS SigV4 signing) to generate + * embeddings. Handles the differing request/response shapes of Amazon Titan Text Embeddings and + * Cohere Embed models. + */ +public class AwsBedrockEmbedder extends Embedder { + + private static final Logger logger = LoggerFactory.getLogger(AwsBedrockEmbedder.class); + + private final String modelId; + private final AwsBedrockPluginOptions options; + private final OkHttpClient client; + private final ObjectMapper objectMapper; + + /** + * Creates a new AwsBedrockEmbedder. + * + * @param modelId the Bedrock embedding model ID (e.g., "amazon.titan-embed-text-v2:0") + * @param options the plugin options + */ + public AwsBedrockEmbedder(String modelId, AwsBedrockPluginOptions options) { + super( + "aws-bedrock/" + modelId, + createEmbedderInfo(modelId), + (ctx, req) -> { + throw new GenkitException("Handler not initialized"); + }); + this.modelId = modelId; + this.options = options; + this.objectMapper = new ObjectMapper(); + this.client = + new OkHttpClient.Builder() + .connectTimeout(120, TimeUnit.SECONDS) + .readTimeout(120, TimeUnit.SECONDS) + .writeTimeout(120, TimeUnit.SECONDS) + .build(); + } + + private static EmbedderInfo createEmbedderInfo(String modelId) { + EmbedderInfo info = new EmbedderInfo(); + info.setLabel("AWS Bedrock " + modelId); + if (modelId.contains("titan-embed-text-v2")) { + info.setDimensions(1024); + } else if (modelId.contains("titan-embed-text-v1")) { + info.setDimensions(1536); + } else if (modelId.startsWith("cohere.embed")) { + info.setDimensions(1024); + } + return info; + } + + private boolean isCohere() { + return modelId.startsWith("cohere.embed") || modelId.contains("cohere.embed"); + } + + @Override + public EmbedResponse run(ActionContext context, EmbedRequest request) { + if (request == null) { + throw new GenkitException( + "Embed request is required. Please provide an input with documents to embed."); + } + if (request.getDocuments() == null || request.getDocuments().isEmpty()) { + throw new GenkitException("Embed request must contain at least one document to embed."); + } + try { + List embeddings = new ArrayList<>(); + for (Document doc : request.getDocuments()) { + String text = doc.text(); + if (text == null || text.isEmpty()) { + logger.warn("Document has empty text, skipping"); + continue; + } + embeddings.add(embedOne(text)); + } + return new EmbedResponse(embeddings); + } catch (IOException e) { + throw new GenkitException("AWS Bedrock Embedding API call failed", e); + } + } + + private EmbedResponse.Embedding embedOne(String text) throws IOException { + ObjectNode body = objectMapper.createObjectNode(); + if (isCohere()) { + body.putArray("texts").add(text); + body.put("input_type", "search_document"); + } else { + // Amazon Titan Text Embeddings + body.put("inputText", text); + } + + String path = String.format("/model/%s/invoke", modelId); + String host = AwsBedrockSigner.runtimeHost(options); + Request httpRequest = AwsBedrockSigner.signRequest(options, host, path, body.toString()); + + try (Response response = client.newCall(httpRequest).execute()) { + if (!response.isSuccessful()) { + String errorBody = response.body() != null ? response.body().string() : "No error body"; + throw new GenkitException( + "AWS Bedrock Embedding API error: " + response.code() + " - " + errorBody); + } + return parseEmbedding(objectMapper.readTree(response.body().string())); + } + } + + private EmbedResponse.Embedding parseEmbedding(JsonNode root) { + // Titan: {"embedding": [...]}. Cohere: {"embeddings": [[...]]} or {"embeddings": {"float": + // [[...]]}}. + JsonNode vector = root.get("embedding"); + if (vector == null) { + JsonNode embeddings = root.get("embeddings"); + if (embeddings != null && embeddings.isArray() && embeddings.size() > 0) { + vector = embeddings.get(0); + } else if (embeddings != null && embeddings.has("float")) { + JsonNode floats = embeddings.get("float"); + if (floats.isArray() && floats.size() > 0) { + vector = floats.get(0); + } + } + } + if (vector == null || !vector.isArray()) { + throw new GenkitException( + "AWS Bedrock embedding response did not contain an embedding vector"); + } + float[] values = new float[vector.size()]; + for (int i = 0; i < vector.size(); i++) { + values[i] = (float) vector.get(i).asDouble(); + } + return new EmbedResponse.Embedding(values); + } +} diff --git a/plugins/aws-bedrock/src/main/java/com/google/genkit/plugins/awsbedrock/AwsBedrockModel.java b/plugins/aws-bedrock/src/main/java/com/google/genkit/plugins/awsbedrock/AwsBedrockModel.java index 5d151b161..2bfa8a639 100644 --- a/plugins/aws-bedrock/src/main/java/com/google/genkit/plugins/awsbedrock/AwsBedrockModel.java +++ b/plugins/aws-bedrock/src/main/java/com/google/genkit/plugins/awsbedrock/AwsBedrockModel.java @@ -33,11 +33,6 @@ import okhttp3.*; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import software.amazon.awssdk.auth.credentials.AwsCredentials; -import software.amazon.awssdk.auth.signer.Aws4Signer; -import software.amazon.awssdk.auth.signer.params.Aws4SignerParams; -import software.amazon.awssdk.http.SdkHttpFullRequest; -import software.amazon.awssdk.http.SdkHttpMethod; /** * AWS Bedrock model implementation for Genkit. @@ -170,50 +165,7 @@ private ModelResponse callBedrock( } private Request signRequest(String host, String path, String body) { - try { - AwsCredentials credentials = options.getCredentialsProvider().resolveCredentials(); - - // Build URI and let it handle encoding properly - java.net.URI uri = java.net.URI.create(String.format("https://%s%s", host, path)); - - SdkHttpFullRequest httpRequest = - SdkHttpFullRequest.builder() - .uri(uri) - .method(SdkHttpMethod.POST) - .putHeader("Content-Type", "application/json; charset=utf-8") - .contentStreamProvider( - () -> - new java.io.ByteArrayInputStream( - body.getBytes(java.nio.charset.StandardCharsets.UTF_8))) - .build(); - - Aws4Signer signer = Aws4Signer.create(); - Aws4SignerParams signerParams = - Aws4SignerParams.builder() - .awsCredentials(credentials) - .signingName("bedrock") - .signingRegion(options.getRegion()) - .build(); - - SdkHttpFullRequest signedRequest = signer.sign(httpRequest, signerParams); - - // Use the signed request's URI directly - Request.Builder okHttpRequestBuilder = - new Request.Builder() - .url(signedRequest.getUri().toURL()) - .post(RequestBody.create(body, JSON_MEDIA_TYPE)); - - signedRequest - .headers() - .forEach( - (key, values) -> { - values.forEach(value -> okHttpRequestBuilder.addHeader(key, value)); - }); - - return okHttpRequestBuilder.build(); - } catch (Exception e) { - throw new GenkitException("Failed to sign AWS request", e); - } + return AwsBedrockSigner.signRequest(options, host, path, body); } private ObjectNode buildRequestBody(ModelRequest request, boolean stream) { diff --git a/plugins/aws-bedrock/src/main/java/com/google/genkit/plugins/awsbedrock/AwsBedrockPlugin.java b/plugins/aws-bedrock/src/main/java/com/google/genkit/plugins/awsbedrock/AwsBedrockPlugin.java index fa3f0a066..e19a1ec64 100644 --- a/plugins/aws-bedrock/src/main/java/com/google/genkit/plugins/awsbedrock/AwsBedrockPlugin.java +++ b/plugins/aws-bedrock/src/main/java/com/google/genkit/plugins/awsbedrock/AwsBedrockPlugin.java @@ -54,8 +54,16 @@ public class AwsBedrockPlugin implements Plugin { private static final Logger logger = LoggerFactory.getLogger(AwsBedrockPlugin.class); + /** Supported AWS Bedrock embedding models (Amazon Titan and Cohere Embed). */ + public static final List SUPPORTED_EMBEDDING_MODELS = + Arrays.asList( + "amazon.titan-embed-text-v2:0", + "cohere.embed-english-v3", + "cohere.embed-multilingual-v3"); + private final AwsBedrockPluginOptions options; private final List customModels = new ArrayList<>(); + private final List customEmbeddingModels = new ArrayList<>(); /** Supported AWS Bedrock models with ON_DEMAND or INFERENCE_PROFILE support. */ public static final List SUPPORTED_MODELS = @@ -68,9 +76,6 @@ public class AwsBedrockPlugin implements Plugin { "amazon.nova-2-lite-v1:0", "amazon.nova-sonic-v1:0", "amazon.nova-2-sonic-v1:0", - // Amazon Titan models - "amazon.titan-tg1-large", - "amazon.titan-text-express-v1", // Anthropic Claude 4 models (INFERENCE_PROFILE) "anthropic.claude-sonnet-4-20250514-v1:0", "anthropic.claude-sonnet-4-5-20250929-v1:0", @@ -91,7 +96,6 @@ public class AwsBedrockPlugin implements Plugin { "anthropic.claude-3-5-sonnet-20241022-v2:0", "anthropic.claude-3-5-sonnet-20240620-v1:0", "anthropic.claude-3-5-haiku-20241022-v1:0", - "anthropic.claude-3-opus-20240229-v1:0", "anthropic.claude-3-sonnet-20240229-v1:0", "anthropic.claude-3-haiku-20240307-v1:0", // AI21 models @@ -101,10 +105,6 @@ public class AwsBedrockPlugin implements Plugin { "meta.llama4-scout-17b-instruct-v1:0", "meta.llama4-maverick-17b-instruct-v1:0", "meta.llama3-3-70b-instruct-v1:0", - "meta.llama3-2-90b-instruct-v1:0", - "meta.llama3-2-11b-instruct-v1:0", - "meta.llama3-2-3b-instruct-v1:0", - "meta.llama3-2-1b-instruct-v1:0", "meta.llama3-1-70b-instruct-v1:0", "meta.llama3-1-8b-instruct-v1:0", "meta.llama3-70b-instruct-v1:0", @@ -115,6 +115,7 @@ public class AwsBedrockPlugin implements Plugin { // Mistral models "mistral.pixtral-large-2502-v1:0", "mistral.mistral-large-3-675b-instruct", + "mistral.devstral-2-123b", "mistral.magistral-small-2509", "mistral.mistral-large-2402-v1:0", "mistral.mistral-small-2402-v1:0", @@ -126,6 +127,8 @@ public class AwsBedrockPlugin implements Plugin { "mistral.voxtral-mini-3b-2507", "mistral.voxtral-small-24b-2507", // DeepSeek models + "deepseek.v3.2", + "deepseek.v3-v1:0", "deepseek.r1-v1:0", // Google Gemma models "google.gemma-3-27b-it", @@ -149,9 +152,16 @@ public class AwsBedrockPlugin implements Plugin { "writer.palmyra-x5-v1:0", "writer.palmyra-x4-v1:0", // MiniMax models + "minimax.minimax-m2.5", + "minimax.minimax-m2.1", "minimax.minimax-m2", // Moonshot models + "moonshotai.kimi-k2.5", "moonshot.kimi-k2-thinking", + // Z.AI models + "zai.glm-5", + "zai.glm-4.7", + "zai.glm-4.7-flash", // TwelveLabs models "twelvelabs.pegasus-1-2-v1:0"); @@ -212,9 +222,24 @@ public String getName() { logger.debug("Registered custom AWS Bedrock model: {}", modelId); } + // Register AWS Bedrock embedding models + for (String modelId : SUPPORTED_EMBEDDING_MODELS) { + AwsBedrockEmbedder embedder = new AwsBedrockEmbedder(modelId, options); + actions.add(embedder); + logger.debug("Registered AWS Bedrock embedder: {}", modelId); + } + + // Register custom embedding models added via customEmbeddingModel() + for (String modelId : customEmbeddingModels) { + AwsBedrockEmbedder embedder = new AwsBedrockEmbedder(modelId, options); + actions.add(embedder); + logger.debug("Registered custom AWS Bedrock embedder: {}", modelId); + } + logger.info( - "AWS Bedrock plugin initialized with {} models in region {} (supports inference profiles)", + "AWS Bedrock plugin initialized with {} models and {} embedders in region {} (supports inference profiles)", SUPPORTED_MODELS.size() + customModels.size(), + SUPPORTED_EMBEDDING_MODELS.size() + customEmbeddingModels.size(), options.getRegion()); return actions; @@ -235,6 +260,19 @@ public AwsBedrockPlugin customModel(String modelId) { return this; } + /** + * Registers a custom embedding model ID. Use this to work with Bedrock embedding models not in + * the default list. Call this method before passing the plugin to Genkit.builder(). + * + * @param modelId the embedding model ID (e.g., "amazon.titan-embed-text-v1") + * @return this plugin instance for method chaining + */ + public AwsBedrockPlugin customEmbeddingModel(String modelId) { + customEmbeddingModels.add(modelId); + logger.debug("Added custom embedding model to be registered: {}", modelId); + return this; + } + /** * Gets the plugin options. * diff --git a/plugins/aws-bedrock/src/main/java/com/google/genkit/plugins/awsbedrock/AwsBedrockSigner.java b/plugins/aws-bedrock/src/main/java/com/google/genkit/plugins/awsbedrock/AwsBedrockSigner.java new file mode 100644 index 000000000..e6f8b72e8 --- /dev/null +++ b/plugins/aws-bedrock/src/main/java/com/google/genkit/plugins/awsbedrock/AwsBedrockSigner.java @@ -0,0 +1,109 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed 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. + * + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.google.genkit.plugins.awsbedrock; + +import com.google.genkit.core.GenkitException; +import okhttp3.MediaType; +import okhttp3.Request; +import okhttp3.RequestBody; +import software.amazon.awssdk.auth.credentials.AwsCredentials; +import software.amazon.awssdk.auth.signer.Aws4Signer; +import software.amazon.awssdk.auth.signer.params.Aws4SignerParams; +import software.amazon.awssdk.http.SdkHttpFullRequest; +import software.amazon.awssdk.http.SdkHttpMethod; + +/** + * Helper for AWS SigV4-signing AWS Bedrock runtime HTTP requests. Shared by {@link AwsBedrockModel} + * (Converse API) and {@link AwsBedrockEmbedder} (InvokeModel API). + */ +final class AwsBedrockSigner { + + private AwsBedrockSigner() {} + + /** + * Returns the {@code bedrock-runtime} host for the configured region. + * + * @param options the plugin options + * @return the host name + */ + static String runtimeHost(AwsBedrockPluginOptions options) { + return String.format("bedrock-runtime.%s.amazonaws.com", options.getRegion().id()); + } + + /** + * Builds a SigV4-signed OkHttp POST request for the AWS Bedrock runtime. + * + * @param options the plugin options (region + credentials) + * @param host the target host (e.g. from {@link #runtimeHost}) + * @param path the request path (e.g. {@code /model//invoke}) + * @param body the JSON request body + * @return a signed OkHttp request + */ + static Request signRequest( + AwsBedrockPluginOptions options, String host, String path, String body) { + try { + AwsCredentials credentials = options.getCredentialsProvider().resolveCredentials(); + + java.net.URI uri = java.net.URI.create(String.format("https://%s%s", host, path)); + + SdkHttpFullRequest httpRequest = + SdkHttpFullRequest.builder() + .uri(uri) + .method(SdkHttpMethod.POST) + .putHeader("Content-Type", "application/json") + .putHeader("Accept", "application/json") + .contentStreamProvider( + () -> + new java.io.ByteArrayInputStream( + body.getBytes(java.nio.charset.StandardCharsets.UTF_8))) + .build(); + + Aws4Signer signer = Aws4Signer.create(); + Aws4SignerParams signerParams = + Aws4SignerParams.builder() + .awsCredentials(credentials) + .signingName("bedrock") + .signingRegion(options.getRegion()) + .build(); + + SdkHttpFullRequest signedRequest = signer.sign(httpRequest, signerParams); + + // Post the body as raw bytes with no OkHttp media type. The String RequestBody overload + // appends "; charset=utf-8" to Content-Type, which the Bedrock InvokeModel API rejects. The + // signed Content-Type ("application/json") and Accept headers copied below are the sole + // source + // of those headers, keeping the request byte-consistent with the SigV4 signature. + Request.Builder okHttpRequestBuilder = + new Request.Builder() + .url(signedRequest.getUri().toURL()) + .post( + RequestBody.create( + body.getBytes(java.nio.charset.StandardCharsets.UTF_8), (MediaType) null)); + + signedRequest + .headers() + .forEach( + (key, values) -> values.forEach(value -> okHttpRequestBuilder.addHeader(key, value))); + + return okHttpRequestBuilder.build(); + } catch (Exception e) { + throw new GenkitException("Failed to sign AWS request", e); + } + } +} diff --git a/plugins/aws-bedrock/src/test/java/com/google/genkit/plugins/awsbedrock/AwsBedrockPluginTest.java b/plugins/aws-bedrock/src/test/java/com/google/genkit/plugins/awsbedrock/AwsBedrockPluginTest.java index af189c58d..0a1b0bf89 100644 --- a/plugins/aws-bedrock/src/test/java/com/google/genkit/plugins/awsbedrock/AwsBedrockPluginTest.java +++ b/plugins/aws-bedrock/src/test/java/com/google/genkit/plugins/awsbedrock/AwsBedrockPluginTest.java @@ -64,6 +64,24 @@ void testSupportedModels() { AwsBedrockPlugin.SUPPORTED_MODELS.contains("anthropic.claude-sonnet-4-20250514-v1:0")); } + @Test + void testSupportedEmbeddingModels() { + assertNotNull(AwsBedrockPlugin.SUPPORTED_EMBEDDING_MODELS); + assertTrue( + AwsBedrockPlugin.SUPPORTED_EMBEDDING_MODELS.contains("amazon.titan-embed-text-v2:0")); + } + + @Test + void testRegistersEmbedders() { + AwsBedrockPluginOptions options = AwsBedrockPluginOptions.builder().region("us-east-1").build(); + AwsBedrockPlugin plugin = new AwsBedrockPlugin(options); + List> actions = plugin.init(); + assertTrue( + actions.stream() + .anyMatch(a -> "aws-bedrock/amazon.titan-embed-text-v2:0".equals(a.getName())), + "Should register the Titan embedder"); + } + @Test void testInitializesActions() { AwsBedrockPluginOptions options = AwsBedrockPluginOptions.builder().region("us-east-1").build(); diff --git a/plugins/azure-foundry/README.md b/plugins/azure-foundry/README.md index f6c3c6877..f021cfc01 100644 --- a/plugins/azure-foundry/README.md +++ b/plugins/azure-foundry/README.md @@ -105,23 +105,23 @@ Genkit genkit = Genkit.builder() ## Supported Models ### Azure OpenAI Models (Global Standard & Provisioned) -- **GPT-5**: `gpt-5`, `gpt-5-mini`, `gpt-5-turbo` -- **o1**: `o1` -- **o3-mini**: `o3-mini-high`, `o3-mini-medium`, `o3-mini-low` -- **GPT-4o**: `gpt-4o`, `gpt-4o-mini` -- **GPT-4**: `gpt-4-turbo`, `gpt-4`, `gpt-35-turbo` +- **GPT-5.5 / 5.4**: `gpt-5.5`, `gpt-5.4`, `gpt-5.4-mini`, `gpt-5.4-nano`, `gpt-5.4-pro` +- **GPT-5**: `gpt-5.2`, `gpt-5.1`, `gpt-5`, `gpt-5-mini`, `gpt-5-nano`, `gpt-5-pro`, `gpt-5-codex` +- **o-series**: `o3`, `o3-pro`, `o3-mini` +- **GPT-4.1**: `gpt-4.1`, `gpt-4.1-mini` +- **GPT-4o / 4**: `gpt-4o`, `gpt-4o-mini`, `gpt-4` ### Azure Models (Sold Directly by Azure) -- **MAI-DS**: `mai-ds-r1` - Deterministic, precision-focused reasoning -- **Grok**: `grok-4`, `grok-4-fast-reasoning`, `grok-4-fast-non-reasoning`, `grok-3`, `grok-3-mini` +- **Grok**: `grok-4`, `grok-4-1-fast-reasoning`, `grok-4-1-fast-non-reasoning`, `grok-code-fast-1` - **Llama**: `llama-3-3-70b-instruct`, `llama-4-maverick-17b-128e-instruct-fp8` -- **DeepSeek**: `deepseek-v3-0324`, `deepseek-v3-1`, `deepseek-r1-0528` +- **DeepSeek**: `DeepSeek-V3.2`, `DeepSeek-V3.2-Speciale` +- **Mistral**: `Mistral-Large-3` - **GPT-OSS**: `gpt-oss-120b` ### Partner and Community Models -- **Claude 4.x**: `claude-opus-4-5`, `claude-opus-4-1`, `claude-sonnet-4-5`, `claude-haiku-4-5` +- **Claude**: `claude-opus-4-8`, `claude-sonnet-5`, `claude-opus-4-7`, `claude-opus-4-6`, `claude-sonnet-4-6`, `claude-opus-4-5`, `claude-opus-4-1`, `claude-sonnet-4-5`, `claude-haiku-4-5` -> **Note:** Model availability varies by region and Azure subscription. Hub-based projects are limited to gpt-4o, gpt-4o-mini, gpt-4, and gpt-35-turbo. See [Azure AI Foundry Model Region Support](https://learn.microsoft.com/en-us/azure/ai-foundry/agents/concepts/model-region-support) for details. +> **Note:** Model availability varies by region and Azure subscription. Hub-based projects are limited to gpt-4o, gpt-4o-mini, and gpt-4. See [Azure AI Foundry Model Region Support](https://learn.microsoft.com/en-us/azure/ai-foundry/agents/concepts/model-region-support) for details. ## Usage Examples diff --git a/plugins/azure-foundry/src/main/java/com/google/genkit/plugins/azurefoundry/AzureFoundryPlugin.java b/plugins/azure-foundry/src/main/java/com/google/genkit/plugins/azurefoundry/AzureFoundryPlugin.java index 5e3827d5d..ae40cd1e4 100644 --- a/plugins/azure-foundry/src/main/java/com/google/genkit/plugins/azurefoundry/AzureFoundryPlugin.java +++ b/plugins/azure-foundry/src/main/java/com/google/genkit/plugins/azurefoundry/AzureFoundryPlugin.java @@ -46,32 +46,43 @@ public class AzureFoundryPlugin implements Plugin { public static final List SUPPORTED_MODELS = Arrays.asList( // Azure OpenAI models (Global Standard & Provisioned) + "gpt-5.5", + "gpt-5.4", + "gpt-5.4-mini", + "gpt-5.4-nano", + "gpt-5.4-pro", + "gpt-5.2", + "gpt-5.1", "gpt-5", "gpt-5-mini", - "gpt-5-turbo", - "o1", - "o3-mini-high", - "o3-mini-medium", - "o3-mini-low", + "gpt-5-nano", + "gpt-5-pro", + "gpt-5-codex", + "o3", + "o3-pro", + "o3-mini", + "gpt-4.1", + "gpt-4.1-mini", "gpt-4o", "gpt-4o-mini", - "gpt-4-turbo", "gpt-4", - "gpt-35-turbo", // Azure models sold directly by Azure - "mai-ds-r1", "grok-4", - "grok-4-fast-reasoning", - "grok-4-fast-non-reasoning", - "grok-3", - "grok-3-mini", + "grok-4-1-fast-reasoning", + "grok-4-1-fast-non-reasoning", + "grok-code-fast-1", "llama-3-3-70b-instruct", "llama-4-maverick-17b-128e-instruct-fp8", - "deepseek-v3-0324", - "deepseek-v3-1", - "deepseek-r1-0528", + "DeepSeek-V3.2", + "DeepSeek-V3.2-Speciale", + "Mistral-Large-3", "gpt-oss-120b", - // Partner and community models + // Partner and community models (Anthropic Claude) + "claude-opus-4-8", + "claude-sonnet-5", + "claude-opus-4-7", + "claude-opus-4-6", + "claude-sonnet-4-6", "claude-opus-4-5", "claude-opus-4-1", "claude-sonnet-4-5", diff --git a/plugins/cohere/README.md b/plugins/cohere/README.md index 890573721..a0b4a2667 100644 --- a/plugins/cohere/README.md +++ b/plugins/cohere/README.md @@ -18,6 +18,16 @@ This plugin provides integration with Cohere models. - `command-r-08-2024` - Balanced model for complex workflows (128K context) - `command-r-plus-08-2024` - Enhanced model for complex RAG and multi-step tool use (128K context) +## Embeddings + +Cohere embedding models are available via the OpenAI-compatible endpoint: + +- `embed-v4.0` - Latest multimodal embeddings (text + images) +- `embed-multilingual-v3.0` +- `embed-english-v3.0` + +Register additional embedding models with `customEmbeddingModel(...)`. + ## Using Custom Models If you need to use a model not in the default list (e.g., a newer model release), register it using `customModel()`: diff --git a/plugins/cohere/src/main/java/com/google/genkit/plugins/cohere/CoherePlugin.java b/plugins/cohere/src/main/java/com/google/genkit/plugins/cohere/CoherePlugin.java index 2ed5f0796..1845994e0 100644 --- a/plugins/cohere/src/main/java/com/google/genkit/plugins/cohere/CoherePlugin.java +++ b/plugins/cohere/src/main/java/com/google/genkit/plugins/cohere/CoherePlugin.java @@ -20,6 +20,7 @@ import com.google.genkit.core.Action; import com.google.genkit.core.Plugin; +import com.google.genkit.plugins.compatoai.CompatOAIEmbedder; import com.google.genkit.plugins.compatoai.CompatOAIModel; import com.google.genkit.plugins.compatoai.CompatOAIPluginOptions; import java.util.ArrayList; @@ -40,13 +41,23 @@ public class CoherePlugin implements Plugin { /** Supported Cohere models. */ public static final List SUPPORTED_MODELS = Arrays.asList( + // Command A family + "command-a-plus-05-2026", + "command-a-reasoning-08-2025", + "command-a-vision-07-2025", "command-a-03-2025", + // Command R family "command-r7b-12-2024", "command-r-08-2024", "command-r-plus-08-2024"); + /** Supported Cohere embedding models (reachable via the OpenAI-compatible endpoint). */ + public static final List SUPPORTED_EMBEDDING_MODELS = + Arrays.asList("embed-v4.0", "embed-multilingual-v3.0", "embed-english-v3.0"); + private final CompatOAIPluginOptions options; private final List customModels = new ArrayList<>(); + private final List customEmbeddingModels = new ArrayList<>(); /** Creates a CoherePlugin with default options (using COHERE_API_KEY environment variable). */ public CoherePlugin() { @@ -128,8 +139,26 @@ public String getName() { logger.debug("Created custom Cohere model: {}", modelName); } + // Register Cohere embedding models + for (String modelName : SUPPORTED_EMBEDDING_MODELS) { + CompatOAIEmbedder embedder = + new CompatOAIEmbedder("cohere/" + modelName, modelName, "Cohere " + modelName, options); + actions.add(embedder); + logger.debug("Created Cohere embedder: {}", modelName); + } + + // Register custom embedding models added via customEmbeddingModel() + for (String modelName : customEmbeddingModels) { + CompatOAIEmbedder embedder = + new CompatOAIEmbedder("cohere/" + modelName, modelName, "Cohere " + modelName, options); + actions.add(embedder); + logger.debug("Created custom Cohere embedder: {}", modelName); + } + logger.info( - "Cohere plugin initialized with {} models", SUPPORTED_MODELS.size() + customModels.size()); + "Cohere plugin initialized with {} models and {} embedders", + SUPPORTED_MODELS.size() + customModels.size(), + SUPPORTED_EMBEDDING_MODELS.size() + customEmbeddingModels.size()); return actions; } @@ -147,6 +176,19 @@ public CoherePlugin customModel(String modelName) { return this; } + /** + * Registers a custom embedding model name. Use this to work with embedding models not in the + * default list. Call this method before passing the plugin to Genkit.builder(). + * + * @param modelName the embedding model name (e.g., "embed-english-light-v3.0") + * @return this plugin instance for method chaining + */ + public CoherePlugin customEmbeddingModel(String modelName) { + customEmbeddingModels.add(modelName); + logger.debug("Added custom embedding model to be registered: {}", modelName); + return this; + } + /** * Gets the plugin options. * diff --git a/plugins/cohere/src/test/java/com/google/genkit/plugins/cohere/CoherePluginTest.java b/plugins/cohere/src/test/java/com/google/genkit/plugins/cohere/CoherePluginTest.java index e2b611192..0a9a80d02 100644 --- a/plugins/cohere/src/test/java/com/google/genkit/plugins/cohere/CoherePluginTest.java +++ b/plugins/cohere/src/test/java/com/google/genkit/plugins/cohere/CoherePluginTest.java @@ -92,6 +92,26 @@ void testSupportedModels() { assertTrue(CoherePlugin.SUPPORTED_MODELS.contains("command-r-plus-08-2024")); } + @Test + void testSupportedEmbeddingModels() { + assertNotNull(CoherePlugin.SUPPORTED_EMBEDDING_MODELS); + assertTrue(CoherePlugin.SUPPORTED_EMBEDDING_MODELS.contains("embed-v4.0")); + } + + @Test + void testRegistersEmbedders() { + CoherePlugin plugin = + new CoherePlugin( + CompatOAIPluginOptions.builder() + .apiKey("test-key") + .baseUrl("https://api.cohere.ai/compatibility/v1") + .build()); + List> actions = plugin.init(); + assertTrue( + actions.stream().anyMatch(a -> "cohere/embed-v4.0".equals(a.getName())), + "Should register the embed-v4.0 embedder"); + } + @Test void testCustomModel() { CoherePlugin plugin = diff --git a/plugins/compat-oai/src/main/java/com/google/genkit/plugins/compatoai/CompatOAIEmbedder.java b/plugins/compat-oai/src/main/java/com/google/genkit/plugins/compatoai/CompatOAIEmbedder.java new file mode 100644 index 000000000..5c2336f4a --- /dev/null +++ b/plugins/compat-oai/src/main/java/com/google/genkit/plugins/compatoai/CompatOAIEmbedder.java @@ -0,0 +1,178 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed 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. + * + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.google.genkit.plugins.compatoai; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.google.genkit.ai.Document; +import com.google.genkit.ai.EmbedRequest; +import com.google.genkit.ai.EmbedResponse; +import com.google.genkit.ai.Embedder; +import com.google.genkit.ai.EmbedderInfo; +import com.google.genkit.core.ActionContext; +import com.google.genkit.core.GenkitException; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.TimeUnit; +import okhttp3.*; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Embedder implementation for any provider that exposes an OpenAI-compatible {@code /embeddings} + * endpoint (e.g. Cohere, Mistral). + * + *

Sends a {@code POST {baseUrl}/embeddings} request with a JSON body of the form {@code + * {"model": "...", "input": ["text", ...]}} and parses the {@code data[].embedding} arrays. + */ +public class CompatOAIEmbedder extends Embedder { + + private static final Logger logger = LoggerFactory.getLogger(CompatOAIEmbedder.class); + private static final MediaType JSON_MEDIA_TYPE = MediaType.parse("application/json"); + + private final String apiModelName; + private final CompatOAIPluginOptions options; + private final OkHttpClient client; + private final ObjectMapper objectMapper; + + /** + * Creates a new CompatOAIEmbedder. + * + * @param modelName the Genkit embedder name (e.g. "cohere/embed-v4.0") + * @param apiModelName the model name sent to the API (e.g. "embed-v4.0") + * @param label the display label (e.g. "Cohere embed-v4.0") + * @param options the plugin options + */ + public CompatOAIEmbedder( + String modelName, String apiModelName, String label, CompatOAIPluginOptions options) { + super( + modelName, + createEmbedderInfo(label), + (ctx, req) -> { + throw new GenkitException("Handler not initialized"); + }); + this.apiModelName = apiModelName; + this.options = options; + this.objectMapper = new ObjectMapper(); + this.client = + new OkHttpClient.Builder() + .connectTimeout(options.getTimeout(), TimeUnit.SECONDS) + .readTimeout(options.getTimeout(), TimeUnit.SECONDS) + .writeTimeout(options.getTimeout(), TimeUnit.SECONDS) + .build(); + } + + private static EmbedderInfo createEmbedderInfo(String label) { + EmbedderInfo info = new EmbedderInfo(); + info.setLabel(label); + return info; + } + + @Override + public EmbedResponse run(ActionContext context, EmbedRequest request) { + if (request == null) { + throw new GenkitException( + "Embed request is required. Please provide an input with documents to embed."); + } + if (request.getDocuments() == null || request.getDocuments().isEmpty()) { + throw new GenkitException("Embed request must contain at least one document to embed."); + } + try { + return callApi(request); + } catch (IOException e) { + throw new GenkitException("Embedding API call failed", e); + } + } + + private String buildUrl() { + StringBuilder url = new StringBuilder(options.getBaseUrl()); + url.append("/embeddings"); + if (options.getQueryParams() != null && !options.getQueryParams().isEmpty()) { + url.append("?"); + boolean first = true; + for (java.util.Map.Entry entry : options.getQueryParams().entrySet()) { + if (!first) { + url.append("&"); + } + url.append(entry.getKey()).append("=").append(entry.getValue()); + first = false; + } + } + return url.toString(); + } + + private EmbedResponse callApi(EmbedRequest request) throws IOException { + ObjectNode requestBody = objectMapper.createObjectNode(); + requestBody.put("model", apiModelName); + + ArrayNode input = requestBody.putArray("input"); + for (Document doc : request.getDocuments()) { + String text = doc.text(); + if (text == null || text.isEmpty()) { + logger.warn("Document has empty text, skipping"); + continue; + } + input.add(text); + } + if (input.isEmpty()) { + throw new GenkitException("No valid documents to embed - all documents had empty text"); + } + + Request.Builder requestBuilder = + new Request.Builder() + .url(buildUrl()) + .header("Authorization", "Bearer " + options.getApiKey()) + .header("Content-Type", "application/json") + .post(RequestBody.create(requestBody.toString(), JSON_MEDIA_TYPE)); + if (options.getOrganization() != null) { + requestBuilder.header("OpenAI-Organization", options.getOrganization()); + } + + try (Response response = client.newCall(requestBuilder.build()).execute()) { + if (!response.isSuccessful()) { + String errorBody = response.body() != null ? response.body().string() : "No error body"; + throw new GenkitException("Embedding API error: " + response.code() + " - " + errorBody); + } + return parseResponse(response.body().string()); + } + } + + private EmbedResponse parseResponse(String responseBody) throws IOException { + JsonNode root = objectMapper.readTree(responseBody); + List embeddings = new ArrayList<>(); + + JsonNode dataNode = root.get("data"); + if (dataNode != null && dataNode.isArray()) { + for (JsonNode item : dataNode) { + JsonNode embeddingNode = item.get("embedding"); + if (embeddingNode != null && embeddingNode.isArray()) { + float[] values = new float[embeddingNode.size()]; + for (int i = 0; i < embeddingNode.size(); i++) { + values[i] = (float) embeddingNode.get(i).asDouble(); + } + embeddings.add(new EmbedResponse.Embedding(values)); + } + } + } + return new EmbedResponse(embeddings); + } +} diff --git a/plugins/deepseek/src/main/java/com/google/genkit/plugins/deepseek/DeepSeekPlugin.java b/plugins/deepseek/src/main/java/com/google/genkit/plugins/deepseek/DeepSeekPlugin.java index 647cce80f..f61e6ec81 100644 --- a/plugins/deepseek/src/main/java/com/google/genkit/plugins/deepseek/DeepSeekPlugin.java +++ b/plugins/deepseek/src/main/java/com/google/genkit/plugins/deepseek/DeepSeekPlugin.java @@ -39,7 +39,13 @@ public class DeepSeekPlugin implements Plugin { /** Supported DeepSeek models. */ public static final List SUPPORTED_MODELS = - Arrays.asList("deepseek-chat", "deepseek-reasoner"); + Arrays.asList( + // DeepSeek V4 (current) + "deepseek-v4-pro", + "deepseek-v4-flash", + // Legacy aliases (route to V4; deprecated, scheduled for retirement 2026-07-24) + "deepseek-chat", + "deepseek-reasoner"); private final CompatOAIPluginOptions options; private final List customModels = new ArrayList<>(); diff --git a/plugins/google-genai/src/main/java/com/google/genkit/plugins/googlegenai/GeminiEmbedder.java b/plugins/google-genai/src/main/java/com/google/genkit/plugins/googlegenai/GeminiEmbedder.java index c097eaa32..42c940ef4 100644 --- a/plugins/google-genai/src/main/java/com/google/genkit/plugins/googlegenai/GeminiEmbedder.java +++ b/plugins/google-genai/src/main/java/com/google/genkit/plugins/googlegenai/GeminiEmbedder.java @@ -46,7 +46,7 @@ public class GeminiEmbedder extends Embedder { /** * Creates a new GeminiEmbedder. * - * @param modelName the embedding model name (e.g., "text-embedding-004", "gemini-embedding-001") + * @param modelName the embedding model name (e.g., "gemini-embedding-2", "gemini-embedding-001") * @param options the plugin options */ public GeminiEmbedder(String modelName, GoogleGenAIPluginOptions options) { diff --git a/plugins/google-genai/src/main/java/com/google/genkit/plugins/googlegenai/GoogleGenAIPlugin.java b/plugins/google-genai/src/main/java/com/google/genkit/plugins/googlegenai/GoogleGenAIPlugin.java index e53c87941..a4b1fd35b 100644 --- a/plugins/google-genai/src/main/java/com/google/genkit/plugins/googlegenai/GoogleGenAIPlugin.java +++ b/plugins/google-genai/src/main/java/com/google/genkit/plugins/googlegenai/GoogleGenAIPlugin.java @@ -32,9 +32,9 @@ *

This plugin provides access to Google's Gemini models for: * *

    - *
  • Text generation (Gemini 2.0, 2.5, 3.0 series) + *
  • Text generation (Gemini 3.5, 3.1, 2.5 series) *
  • Multimodal content (images, video, audio) - *
  • Embeddings (text-embedding-004, gemini-embedding-001) + *
  • Embeddings (gemini-embedding-2, gemini-embedding-001) *
  • Function calling/tools *
* @@ -67,7 +67,7 @@ * // Generate content * GenerateResponse response = genkit.generate( * GenerateOptions.builder() - * .model("googleai/gemini-2.0-flash") + * .model("googleai/gemini-2.5-flash") * .prompt("Hello, world!") * .build()); * }
@@ -79,33 +79,26 @@ public class GoogleGenAIPlugin implements Plugin { /** Supported Gemini models for text/multimodal generation. */ public static final List SUPPORTED_MODELS = Arrays.asList( - // Gemini 3.0 series - "gemini-3-pro-preview", + // Gemini 3.5 / 3.1 series + "gemini-3.5-flash", + "gemini-3.1-pro-preview", + "gemini-3.1-flash-lite", "gemini-3-flash-preview", // Gemini 2.5 series "gemini-2.5-pro", "gemini-2.5-flash", "gemini-2.5-flash-lite", - // Gemini 2.0 series - "gemini-2.0-flash", - "gemini-2.0-flash-lite", - // Gemini 1.5 series (still widely used) - "gemini-1.5-pro", - "gemini-1.5-flash", - "gemini-1.5-flash-8b", // Gemma models - "gemma-3-12b-it", - "gemma-3-27b-it", - "gemma-3-4b-it", - "gemma-3-1b-it", - "gemma-3n-e4b-it"); + "gemma-4-31b-it", + "gemma-4-26b-a4b-it"); /** Supported embedding models. */ public static final List SUPPORTED_EMBEDDING_MODELS = Arrays.asList( - "text-embedding-004", - "text-embedding-005", + "gemini-embedding-2", "gemini-embedding-001", + // Vertex AI embedding models + "text-embedding-005", "text-multilingual-embedding-002"); /** @@ -113,20 +106,28 @@ public class GoogleGenAIPlugin implements Plugin { * Gemini Developer API. imagen-3.0-* models require Vertex AI. */ public static final List SUPPORTED_IMAGE_MODELS = - Arrays.asList("imagen-4.0-generate-001", "imagen-4.0-fast-generate-001"); + Arrays.asList( + "imagen-4.0-generate-001", + "imagen-4.0-fast-generate-001", + "imagen-4.0-ultra-generate-001"); /** Supported TTS models. */ public static final List SUPPORTED_TTS_MODELS = - Arrays.asList("gemini-2.5-flash-preview-tts", "gemini-2.5-pro-preview-tts"); + Arrays.asList( + "gemini-3.1-flash-tts-preview", + "gemini-2.5-flash-preview-tts", + "gemini-2.5-pro-preview-tts"); /** Supported video generation models (Veo). */ public static final List SUPPORTED_VEO_MODELS = Arrays.asList( - "veo-2.0-generate-001", - "veo-3.0-generate-001", - "veo-3.0-fast-generate-001", "veo-3.1-generate-preview", - "veo-3.1-fast-generate-preview"); + "veo-3.1-fast-generate-preview", + "veo-3.1-lite-generate-preview"); + + /** Supported Gemini Omni video generation/editing models (Interactions API). */ + public static final List SUPPORTED_OMNI_MODELS = + Arrays.asList("gemini-omni-flash-preview"); private final GoogleGenAIPluginOptions options; private final List customModels = new ArrayList<>(); @@ -134,6 +135,7 @@ public class GoogleGenAIPlugin implements Plugin { private final List customImageModels = new ArrayList<>(); private final List customTtsModels = new ArrayList<>(); private final List customVeoModels = new ArrayList<>(); + private final List customOmniModels = new ArrayList<>(); /** * Creates a GoogleGenAIPlugin with default options. Reads API key from GOOGLE_API_KEY or @@ -276,14 +278,30 @@ public String getName() { logger.debug("Created custom Veo model: {}", modelName); } + // Register Gemini Omni video generation/editing models (Interactions API) + for (String modelName : SUPPORTED_OMNI_MODELS) { + OmniModel model = new OmniModel(modelName, options); + actions.add(model); + logger.debug("Created Omni model: {}", modelName); + } + + // Register custom Omni models + for (String modelName : customOmniModels) { + OmniModel model = new OmniModel(modelName, options); + actions.add(model); + logger.debug("Created custom Omni model: {}", modelName); + } + String backend = options.isVertexAI() ? "Vertex AI" : "Gemini Developer API"; logger.info( - "Google GenAI plugin initialized with {} models, {} embedders, {} image models, {} TTS models, and {} video models using {}", + "Google GenAI plugin initialized with {} models, {} embedders, {} image models, {} TTS" + + " models, {} video models, and {} omni models using {}", SUPPORTED_MODELS.size() + customModels.size(), SUPPORTED_EMBEDDING_MODELS.size() + customEmbeddingModels.size(), SUPPORTED_IMAGE_MODELS.size() + customImageModels.size(), SUPPORTED_TTS_MODELS.size() + customTtsModels.size(), SUPPORTED_VEO_MODELS.size() + customVeoModels.size(), + SUPPORTED_OMNI_MODELS.size() + customOmniModels.size(), backend); return actions; @@ -354,6 +372,19 @@ public GoogleGenAIPlugin customVeoModel(String modelName) { return this; } + /** + * Registers a custom Gemini Omni model name. Use this to work with Omni (Interactions API) models + * not in the default list. Call this method before passing the plugin to Genkit.builder(). + * + * @param modelName the Omni model name (e.g., "gemini-omni-pro-preview") + * @return this plugin instance for method chaining + */ + public GoogleGenAIPlugin customOmniModel(String modelName) { + customOmniModels.add(modelName); + logger.debug("Added custom Omni model to be registered: {}", modelName); + return this; + } + /** * Gets the plugin options. * diff --git a/plugins/google-genai/src/main/java/com/google/genkit/plugins/googlegenai/ImagenModel.java b/plugins/google-genai/src/main/java/com/google/genkit/plugins/googlegenai/ImagenModel.java index 5c647665f..1dc1c9cfc 100644 --- a/plugins/google-genai/src/main/java/com/google/genkit/plugins/googlegenai/ImagenModel.java +++ b/plugins/google-genai/src/main/java/com/google/genkit/plugins/googlegenai/ImagenModel.java @@ -73,7 +73,7 @@ public class ImagenModel implements Model { /** * Creates a new ImagenModel. * - * @param modelName the model name (e.g., "imagen-3.0-generate-002") + * @param modelName the model name (e.g., "imagen-4.0-generate-001") * @param options the plugin options */ public ImagenModel(String modelName, GoogleGenAIPluginOptions options) { diff --git a/plugins/google-genai/src/main/java/com/google/genkit/plugins/googlegenai/OmniModel.java b/plugins/google-genai/src/main/java/com/google/genkit/plugins/googlegenai/OmniModel.java new file mode 100644 index 000000000..0352ce4ce --- /dev/null +++ b/plugins/google-genai/src/main/java/com/google/genkit/plugins/googlegenai/OmniModel.java @@ -0,0 +1,397 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed 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. + * + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.google.genkit.plugins.googlegenai; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.google.genkit.ai.Candidate; +import com.google.genkit.ai.FinishReason; +import com.google.genkit.ai.Media; +import com.google.genkit.ai.Message; +import com.google.genkit.ai.Model; +import com.google.genkit.ai.ModelInfo; +import com.google.genkit.ai.ModelRequest; +import com.google.genkit.ai.ModelResponse; +import com.google.genkit.ai.ModelResponseChunk; +import com.google.genkit.ai.Part; +import com.google.genkit.ai.Role; +import com.google.genkit.core.ActionContext; +import com.google.genkit.core.GenkitException; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.function.Consumer; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Gemini Omni video generation and editing model using the Gemini Interactions API. + * + *

Gemini Omni ({@code gemini-omni-flash-preview}) natively processes text, image, audio, and + * video and produces video (with audio). It is served by the Interactions API ({@code POST + * /v1beta/interactions}) rather than {@code generateContent}, and supports conversational + * editing: each turn can build on a previous result. + * + *

Conversational editing. The Interactions API is stateful server-side. This model + * bridges it to Genkit's stateless {@link Model} interface as follows: + * + *

    + *
  • On the first turn, send a prompt (and optional media). The response's {@code custom} map + * carries the returned {@code interactionId}. + *
  • To iteratively edit, pass that id back as the {@code previousInteractionId} config option + * on the next {@code generate} call, with the edit instruction as the prompt. The model + * applies the change while preserving elements you did not mention. + *
+ * + *

Supported config options (via the request config map): {@code previousInteractionId}, {@code + * aspectRatio} (e.g. "16:9"), {@code duration} (e.g. "10s"), {@code delivery} ("inline" (default) | + * "uri"), {@code task} ("text_to_video" | "image_to_video"), {@code thinkingLevel}, {@code + * maxOutputTokens}. + * + *

Only the Gemini Developer API (API key) is supported; Vertex AI is not. + * + *

Note: the Interactions API is in preview; request/response field shapes may evolve. + */ +public class OmniModel implements Model { + + private static final Logger logger = LoggerFactory.getLogger(OmniModel.class); + + private final String modelName; + private final GoogleGenAIPluginOptions options; + private final HttpClient httpClient; + private final ObjectMapper objectMapper; + private final ModelInfo info; + + /** + * Creates a new OmniModel. + * + * @param modelName the model name (e.g., "gemini-omni-flash-preview") + * @param options the plugin options + */ + public OmniModel(String modelName, GoogleGenAIPluginOptions options) { + this.modelName = modelName; + this.options = options; + this.objectMapper = new ObjectMapper(); + this.httpClient = + HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(options.getTimeout())).build(); + this.info = createModelInfo(); + } + + private ModelInfo createModelInfo() { + ModelInfo info = new ModelInfo(); + info.setLabel("Google AI " + modelName); + + ModelInfo.ModelCapabilities caps = new ModelInfo.ModelCapabilities(); + caps.setMultiturn(true); // conversational editing + caps.setMedia(true); // accepts text/image/audio/video input + caps.setTools(false); + caps.setSystemRole(false); + caps.setOutput(Set.of("media")); + info.setSupports(caps); + + return info; + } + + @Override + public String getName() { + return "googleai/" + modelName; + } + + @Override + public ModelInfo getInfo() { + return info; + } + + @Override + public boolean supportsStreaming() { + return false; + } + + @Override + public ModelResponse run(ActionContext context, ModelRequest request) { + if (options.isVertexAI()) { + throw new GenkitException( + "Gemini Omni (Interactions API) is not supported on Vertex AI. Use the Gemini Developer" + + " API with an API key."); + } + try { + return callInteractions(request); + } catch (GenkitException e) { + throw e; + } catch (Exception e) { + throw new GenkitException("Gemini Omni API call failed: " + e.getMessage(), e); + } + } + + @Override + public ModelResponse run( + ActionContext context, ModelRequest request, Consumer streamCallback) { + // The Interactions API is not streamed; return the full result. + return run(context, request); + } + + private ModelResponse callInteractions(ModelRequest request) throws Exception { + Map config = request.getConfig(); + String previousInteractionId = configString(config, "previousInteractionId", null); + String delivery = configString(config, "delivery", null); // "inline" (default) | "uri" + String aspectRatio = configString(config, "aspectRatio", null); // e.g. "16:9" + String duration = configString(config, "duration", null); // e.g. "10s" + String thinkingLevel = configString(config, "thinkingLevel", "high"); + int maxOutputTokens = configInt(config, "maxOutputTokens", 65536); + boolean isEdit = previousInteractionId != null && !previousInteractionId.isEmpty(); + + // Collect the latest user prompt text and any media parts. + String promptText = latestUserText(request); + List mediaParts = latestUserMedia(request); + String task = + configString(config, "task", mediaParts.isEmpty() ? "text_to_video" : "image_to_video"); + + ObjectNode body = objectMapper.createObjectNode(); + body.put("model", "models/" + modelName); + + if (isEdit) { + // Conversational editing turn: reference the prior interaction. + body.put("previous_interaction_id", previousInteractionId); + body.put("input", promptText); + } else if (mediaParts.isEmpty()) { + // Text-to-video: input is a plain prompt string. + body.put("input", promptText); + } else { + // Media-conditioned generation: input is an array of typed objects. + ArrayNode input = body.putArray("input"); + if (promptText != null && !promptText.isEmpty()) { + ObjectNode textNode = input.addObject(); + textNode.put("type", "text"); + textNode.put("text", promptText); + } + for (Part part : mediaParts) { + input.add(mediaToInput(part.getMedia())); + } + } + + // Every turn requests video output. + body.putArray("response_modalities").add("video"); + ObjectNode responseFormat = body.putObject("response_format"); + responseFormat.put("type", "video"); + if (aspectRatio != null) { + responseFormat.put("aspect_ratio", aspectRatio); + } + if (duration != null) { + responseFormat.put("duration", duration); + } + if (delivery != null) { + // Supported values: "inline" (base64, default) or "uri". + responseFormat.put("delivery", delivery); + } + + // Generation config applies to the initial generation turn. + if (!isEdit) { + ObjectNode generationConfig = body.putObject("generation_config"); + generationConfig.put("max_output_tokens", maxOutputTokens); + if (thinkingLevel != null && !thinkingLevel.isEmpty()) { + generationConfig.put("thinking_level", thinkingLevel); + } + ObjectNode videoConfig = generationConfig.putObject("video_config"); + videoConfig.put("task", task); + } + + HttpRequest httpRequest = + HttpRequest.newBuilder() + .uri(URI.create("https://generativelanguage.googleapis.com/v1beta/interactions")) + .timeout(Duration.ofSeconds(options.getTimeout())) + .header("x-goog-api-key", options.getApiKey()) + .header("Content-Type", "application/json") + .POST(HttpRequest.BodyPublishers.ofString(body.toString())) + .build(); + + HttpResponse response = + httpClient.send(httpRequest, HttpResponse.BodyHandlers.ofString()); + if (response.statusCode() < 200 || response.statusCode() >= 300) { + throw new GenkitException( + "Gemini Omni API error: " + response.statusCode() + " - " + response.body()); + } + + return parseResponse(objectMapper.readTree(response.body())); + } + + private ObjectNode mediaToInput(Media media) { + // Best-effort mapping of inline media to an Interactions API input object. + ObjectNode node = objectMapper.createObjectNode(); + String contentType = media.getContentType() != null ? media.getContentType() : ""; + String type = contentType.startsWith("video/") ? "video" : "image"; + node.put("type", type); + String url = media.getUrl(); + ObjectNode source = node.putObject("source"); + if (url != null && url.startsWith("data:")) { + String[] parts = url.split(",", 2); + String mediaType = parts[0].substring(5, parts[0].indexOf(';')); + source.put("type", "base64"); + source.put("media_type", mediaType); + source.put("data", parts.length > 1 ? parts[1] : ""); + } else { + source.put("type", "uri"); + source.put("uri", url); + } + return node; + } + + private ModelResponse parseResponse(JsonNode root) { + ModelResponse modelResponse = new ModelResponse(); + List candidates = new ArrayList<>(); + Candidate candidate = new Candidate(); + + Message message = new Message(); + message.setRole(Role.MODEL); + List parts = new ArrayList<>(); + + // Walk the interaction steps and collect video output from the model_output step(s). + JsonNode steps = root.get("steps"); + if (steps != null && steps.isArray()) { + for (JsonNode step : steps) { + if (!"model_output".equals(step.path("type").asText())) { + continue; + } + JsonNode content = step.get("content"); + if (content == null || !content.isArray()) { + continue; + } + for (JsonNode item : content) { + if (!"video".equals(item.path("type").asText())) { + continue; + } + String mimeType = item.path("mime_type").asText("video/mp4"); + String url; + if (item.hasNonNull("data")) { + url = "data:" + mimeType + ";base64," + item.get("data").asText(); + } else if (item.hasNonNull("uri")) { + url = item.get("uri").asText(); + } else { + continue; + } + Part videoPart = new Part(); + videoPart.setMedia(new Media(mimeType, url)); + parts.add(videoPart); + } + } + } + + message.setContent(parts); + candidate.setMessage(message); + candidate.setFinishReason(FinishReason.STOP); + + // Expose the interaction id so the caller can continue editing (previousInteractionId). + String interactionId = root.path("id").asText(null); + String status = root.path("status").asText(null); + if (interactionId != null) { + Map custom = new HashMap<>(); + custom.put("interactionId", interactionId); + if (status != null) { + custom.put("status", status); + } + candidate.setCustom(custom); + modelResponse.setCustom(custom); + } + + if (parts.isEmpty()) { + logger.warn("Gemini Omni response contained no video output (status: {})", status); + } + + candidates.add(candidate); + modelResponse.setCandidates(candidates); + return modelResponse; + } + + private static String configString(Map config, String key, String defaultValue) { + if (config == null) { + return defaultValue; + } + Object value = config.get(key); + return value != null ? value.toString() : defaultValue; + } + + private static int configInt(Map config, String key, int defaultValue) { + if (config == null) { + return defaultValue; + } + Object value = config.get(key); + if (value instanceof Number) { + return ((Number) value).intValue(); + } + if (value != null) { + try { + return Integer.parseInt(value.toString()); + } catch (NumberFormatException e) { + return defaultValue; + } + } + return defaultValue; + } + + private String latestUserText(ModelRequest request) { + if (request.getMessages() == null) { + return ""; + } + for (int i = request.getMessages().size() - 1; i >= 0; i--) { + Message message = request.getMessages().get(i); + if (message.getRole() != Role.USER) { + continue; + } + StringBuilder sb = new StringBuilder(); + for (Part part : message.getContent()) { + if (part.getText() != null) { + if (sb.length() > 0) { + sb.append("\n"); + } + sb.append(part.getText()); + } + } + return sb.toString(); + } + return ""; + } + + private List latestUserMedia(ModelRequest request) { + List media = new ArrayList<>(); + if (request.getMessages() == null) { + return media; + } + for (int i = request.getMessages().size() - 1; i >= 0; i--) { + Message message = request.getMessages().get(i); + if (message.getRole() != Role.USER) { + continue; + } + for (Part part : message.getContent()) { + if (part.getMedia() != null) { + media.add(part); + } + } + return media; + } + return media; + } +} diff --git a/plugins/google-genai/src/main/java/com/google/genkit/plugins/googlegenai/TtsModel.java b/plugins/google-genai/src/main/java/com/google/genkit/plugins/googlegenai/TtsModel.java index 34642b08a..8c046f544 100644 --- a/plugins/google-genai/src/main/java/com/google/genkit/plugins/googlegenai/TtsModel.java +++ b/plugins/google-genai/src/main/java/com/google/genkit/plugins/googlegenai/TtsModel.java @@ -77,7 +77,10 @@ public class TtsModel implements Model { private static final Logger logger = LoggerFactory.getLogger(TtsModel.class); private static final Set SUPPORTED_TTS_MODELS = - Set.of("gemini-2.5-flash-preview-tts", "gemini-2.5-pro-preview-tts"); + Set.of( + "gemini-3.1-flash-tts-preview", + "gemini-2.5-flash-preview-tts", + "gemini-2.5-pro-preview-tts"); private final String modelName; private final GoogleGenAIPluginOptions options; @@ -183,7 +186,7 @@ public ModelResponse run( } private ModelResponse callTts(ModelRequest request) throws Exception { - String prompt = extractPrompt(request); + String prompt = framePrompt(extractPrompt(request), request.getConfig()); GenerateContentConfig config = buildConfig(request); logger.debug("Calling TTS model {} with prompt length: {}", modelName, prompt.length()); @@ -214,6 +217,33 @@ private String extractPrompt(ModelRequest request) { return prompt.toString(); } + /** + * Frames the transcript with a clear synthesis preamble so the TTS model treats the input as text + * to speak rather than a request to answer. + * + *

Gemini TTS models reject "vague" prompts with a 400 error ({@code "Model tried to generate + * text, but it should only be used for TTS"}). Google's guidance is to add a preamble instructing + * the model to synthesize speech and to explicitly label where the transcript begins. + * + *

Callers that frame the prompt themselves (for example {@code "Say cheerfully: ..."}) can set + * a {@code ttsInstruction} config value: a custom preamble string, or an empty string to send the + * prompt verbatim with no framing. + * + * @param text the transcript to speak + * @param config the request config (may be null) + * @return the framed prompt + */ + static String framePrompt(String text, Map config) { + if (config != null && config.containsKey("ttsInstruction")) { + Object instruction = config.get("ttsInstruction"); + String instr = instruction != null ? instruction.toString() : ""; + return instr.isEmpty() ? text : instr + "\n\n" + text; + } + return "Read the following transcript aloud verbatim, generating only speech audio. Do not" + + " respond to it or add any commentary.\n\nTranscript:\n" + + text; + } + private GenerateContentConfig buildConfig(ModelRequest request) { GenerateContentConfig.Builder configBuilder = GenerateContentConfig.builder(); diff --git a/plugins/google-genai/src/main/java/com/google/genkit/plugins/googlegenai/VeoModel.java b/plugins/google-genai/src/main/java/com/google/genkit/plugins/googlegenai/VeoModel.java index 2d997d73c..94be4c27c 100644 --- a/plugins/google-genai/src/main/java/com/google/genkit/plugins/googlegenai/VeoModel.java +++ b/plugins/google-genai/src/main/java/com/google/genkit/plugins/googlegenai/VeoModel.java @@ -61,11 +61,9 @@ *

Supported models: * *

    - *
  • veo-2.0-generate-001 - *
  • veo-3.0-generate-001 - *
  • veo-3.0-fast-generate-001 *
  • veo-3.1-generate-preview *
  • veo-3.1-fast-generate-preview + *
  • veo-3.1-lite-generate-preview *
* *

Configuration options (via custom config): @@ -90,11 +88,9 @@ public class VeoModel implements Model { private static final Set SUPPORTED_VEO_MODELS = Set.of( - "veo-2.0-generate-001", - "veo-3.0-generate-001", - "veo-3.0-fast-generate-001", "veo-3.1-generate-preview", - "veo-3.1-fast-generate-preview"); + "veo-3.1-fast-generate-preview", + "veo-3.1-lite-generate-preview"); private static final long DEFAULT_POLL_INTERVAL_MS = 5000; private static final long DEFAULT_TIMEOUT_MS = 300000; // 5 minutes diff --git a/plugins/google-genai/src/test/java/com/google/genkit/plugins/googlegenai/GoogleGenAIPluginTest.java b/plugins/google-genai/src/test/java/com/google/genkit/plugins/googlegenai/GoogleGenAIPluginTest.java index 3748e1407..d41fb0bf2 100644 --- a/plugins/google-genai/src/test/java/com/google/genkit/plugins/googlegenai/GoogleGenAIPluginTest.java +++ b/plugins/google-genai/src/test/java/com/google/genkit/plugins/googlegenai/GoogleGenAIPluginTest.java @@ -80,6 +80,27 @@ void testSupportedModels() { assertTrue(GoogleGenAIPlugin.SUPPORTED_MODELS.size() > 0); } + @Test + void testSupportedOmniModels() { + assertNotNull(GoogleGenAIPlugin.SUPPORTED_OMNI_MODELS); + assertTrue(GoogleGenAIPlugin.SUPPORTED_OMNI_MODELS.contains("gemini-omni-flash-preview")); + } + + @Test + void testSupportedTtsModelsIncludesLatest() { + assertTrue(GoogleGenAIPlugin.SUPPORTED_TTS_MODELS.contains("gemini-3.1-flash-tts-preview")); + } + + @Test + void testRegistersOmniModel() { + GoogleGenAIPlugin plugin = + new GoogleGenAIPlugin(GoogleGenAIPluginOptions.builder().apiKey("test-key").build()); + List> actions = plugin.init(); + assertTrue( + actions.stream().anyMatch(a -> "googleai/gemini-omni-flash-preview".equals(a.getName())), + "Should register the Gemini Omni model"); + } + @Test void testGetOptions() { GoogleGenAIPluginOptions options = diff --git a/plugins/google-genai/src/test/java/com/google/genkit/plugins/googlegenai/TtsModelTest.java b/plugins/google-genai/src/test/java/com/google/genkit/plugins/googlegenai/TtsModelTest.java new file mode 100644 index 000000000..4f88fb0b8 --- /dev/null +++ b/plugins/google-genai/src/test/java/com/google/genkit/plugins/googlegenai/TtsModelTest.java @@ -0,0 +1,52 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed 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. + * + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.google.genkit.plugins.googlegenai; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Map; +import org.junit.jupiter.api.Test; + +/** Tests for {@link TtsModel} prompt framing. */ +class TtsModelTest { + + @Test + void framePromptAddsPreambleForPlainText() { + String framed = TtsModel.framePrompt("Have a wonderful day", null); + assertTrue(framed.contains("Have a wonderful day"), "Should keep the transcript"); + assertTrue(framed.toLowerCase().contains("transcript"), "Should label the transcript"); + assertNotEquals( + "Have a wonderful day", framed, "Plain text should be framed with a synthesis preamble"); + } + + @Test + void framePromptRespectsEmptyInstructionOverride() { + String framed = TtsModel.framePrompt("Say cheerfully: Hi", Map.of("ttsInstruction", "")); + assertEquals( + "Say cheerfully: Hi", framed, "Empty ttsInstruction should send the prompt verbatim"); + } + + @Test + void framePromptUsesCustomInstruction() { + String framed = TtsModel.framePrompt("Hello", Map.of("ttsInstruction", "Say slowly:")); + assertEquals("Say slowly:\n\nHello", framed); + } +} diff --git a/plugins/groq/README.md b/plugins/groq/README.md index 2ed30fe5d..39edcb810 100644 --- a/plugins/groq/README.md +++ b/plugins/groq/README.md @@ -19,8 +19,9 @@ This plugin provides integration with Groq's ultra-fast LLM inference. - `openai/gpt-oss-120b` - OpenAI GPT-OSS 120B with reasoning (~500 tokens/sec) - `openai/gpt-oss-20b` - OpenAI GPT-OSS 20B (~1000 tokens/sec) -### Content Moderation -- `meta-llama/llama-guard-4-12b` - Content moderation model (~1200 tokens/sec) +### Agentic Systems +- `groq/compound` - Agentic system with built-in web search + code execution +- `groq/compound-mini` - Lightweight agentic system ## Using Custom Models diff --git a/plugins/groq/src/main/java/com/google/genkit/plugins/groq/GroqPlugin.java b/plugins/groq/src/main/java/com/google/genkit/plugins/groq/GroqPlugin.java index 3b59469df..4f355240f 100644 --- a/plugins/groq/src/main/java/com/google/genkit/plugins/groq/GroqPlugin.java +++ b/plugins/groq/src/main/java/com/google/genkit/plugins/groq/GroqPlugin.java @@ -43,11 +43,14 @@ public class GroqPlugin implements Plugin { // Meta Llama models "llama-3.1-8b-instant", "llama-3.3-70b-versatile", - "meta-llama/llama-guard-4-12b", // OpenAI GPT-OSS models "openai/gpt-oss-120b", - "openai/gpt-oss-20b"); + "openai/gpt-oss-20b", + + // Groq agentic systems (built-in web search + code execution) + "groq/compound", + "groq/compound-mini"); private final CompatOAIPluginOptions options; private final List customModels = new ArrayList<>(); diff --git a/plugins/mistral/README.md b/plugins/mistral/README.md index ddf92fdba..6f06009ff 100644 --- a/plugins/mistral/README.md +++ b/plugins/mistral/README.md @@ -13,14 +13,21 @@ This plugin provides integration with Mistral AI models. ## Supported Models -- `mistral-large-3-25-12` - Latest flagship multimodal model (256K context) -- `mistral-medium-3-1-25-08` - Balanced performance (128K context) -- `mistral-small-3-2-25-06` - Efficient and fast (128K context) -- `ministral-3-3b-25-12` - Compact 3B model (128K context) -- `ministral-3-8b-25-12` - Balanced 8B model (128K context) -- `ministral-3-14b-25-12` - Advanced 14B model (128K context) -- `codestral-25-08` - Code generation specialist (256K context) -- `devstral-2-25-12` - Developer-focused model +- `mistral-large-2512` - Flagship model (256K context) +- `mistral-medium-2604` - Mistral Medium 3.5 (128K context) +- `mistral-small-2603` - Mistral Small 4 (128K context) +- `magistral-medium-2509`, `magistral-small-2509` - Reasoning models +- `ministral-3b-2512`, `ministral-8b-2512`, `ministral-14b-2512` - Compact models +- `codestral-2508` - Code generation specialist (256K context) +- `devstral-2512` - Developer/agentic coding model +- `open-mistral-nemo` - Open-source multilingual model + +## Embeddings + +- `mistral-embed` +- `codestral-embed` + +Register additional embedding models with `customEmbeddingModel(...)`. ## Using Custom Models diff --git a/plugins/mistral/src/main/java/com/google/genkit/plugins/mistral/MistralPlugin.java b/plugins/mistral/src/main/java/com/google/genkit/plugins/mistral/MistralPlugin.java index 03bb33669..6d4ecd838 100644 --- a/plugins/mistral/src/main/java/com/google/genkit/plugins/mistral/MistralPlugin.java +++ b/plugins/mistral/src/main/java/com/google/genkit/plugins/mistral/MistralPlugin.java @@ -20,6 +20,7 @@ import com.google.genkit.core.Action; import com.google.genkit.core.Plugin; +import com.google.genkit.plugins.compatoai.CompatOAIEmbedder; import com.google.genkit.plugins.compatoai.CompatOAIModel; import com.google.genkit.plugins.compatoai.CompatOAIPluginOptions; import java.util.ArrayList; @@ -42,7 +43,9 @@ public class MistralPlugin implements Plugin { Arrays.asList( // Flagship models "mistral-large-2512", + "mistral-medium-2604", "mistral-medium-2508", + "mistral-small-2603", "mistral-small-2506", // Reasoning models @@ -54,9 +57,6 @@ public class MistralPlugin implements Plugin { "ministral-8b-2512", "ministral-14b-2512", - // Vision models - "pixtral-large-2411", - // Code models "codestral-2508", "devstral-2512", @@ -64,8 +64,13 @@ public class MistralPlugin implements Plugin { // Open source "open-mistral-nemo"); + /** Supported Mistral embedding models. */ + public static final List SUPPORTED_EMBEDDING_MODELS = + Arrays.asList("mistral-embed", "codestral-embed"); + private final CompatOAIPluginOptions options; private final List customModels = new ArrayList<>(); + private final List customEmbeddingModels = new ArrayList<>(); /** Creates a MistralPlugin with default options (using MISTRAL_API_KEY environment variable). */ public MistralPlugin() { @@ -148,8 +153,26 @@ public String getName() { logger.debug("Created custom Mistral model: {}", modelName); } + // Register Mistral embedding models + for (String modelName : SUPPORTED_EMBEDDING_MODELS) { + CompatOAIEmbedder embedder = + new CompatOAIEmbedder("mistral/" + modelName, modelName, "Mistral " + modelName, options); + actions.add(embedder); + logger.debug("Created Mistral embedder: {}", modelName); + } + + // Register custom embedding models added via customEmbeddingModel() + for (String modelName : customEmbeddingModels) { + CompatOAIEmbedder embedder = + new CompatOAIEmbedder("mistral/" + modelName, modelName, "Mistral " + modelName, options); + actions.add(embedder); + logger.debug("Created custom Mistral embedder: {}", modelName); + } + logger.info( - "Mistral plugin initialized with {} models", SUPPORTED_MODELS.size() + customModels.size()); + "Mistral plugin initialized with {} models and {} embedders", + SUPPORTED_MODELS.size() + customModels.size(), + SUPPORTED_EMBEDDING_MODELS.size() + customEmbeddingModels.size()); return actions; } @@ -167,6 +190,19 @@ public MistralPlugin customModel(String modelName) { return this; } + /** + * Registers a custom embedding model name. Use this to work with embedding models not in the + * default list. Call this method before passing the plugin to Genkit.builder(). + * + * @param modelName the embedding model name (e.g., "mistral-embed-2601") + * @return this plugin instance for method chaining + */ + public MistralPlugin customEmbeddingModel(String modelName) { + customEmbeddingModels.add(modelName); + logger.debug("Added custom embedding model to be registered: {}", modelName); + return this; + } + /** * Gets the plugin options. * diff --git a/plugins/mistral/src/test/java/com/google/genkit/plugins/mistral/MistralPluginTest.java b/plugins/mistral/src/test/java/com/google/genkit/plugins/mistral/MistralPluginTest.java index 01cc3ef9e..d7feab971 100644 --- a/plugins/mistral/src/test/java/com/google/genkit/plugins/mistral/MistralPluginTest.java +++ b/plugins/mistral/src/test/java/com/google/genkit/plugins/mistral/MistralPluginTest.java @@ -92,6 +92,26 @@ void testSupportedModels() { assertTrue(MistralPlugin.SUPPORTED_MODELS.contains("mistral-small-2506")); } + @Test + void testSupportedEmbeddingModels() { + assertNotNull(MistralPlugin.SUPPORTED_EMBEDDING_MODELS); + assertTrue(MistralPlugin.SUPPORTED_EMBEDDING_MODELS.contains("mistral-embed")); + } + + @Test + void testRegistersEmbedders() { + MistralPlugin plugin = + new MistralPlugin( + CompatOAIPluginOptions.builder() + .apiKey("test-key") + .baseUrl("https://api.mistral.ai/v1") + .build()); + List> actions = plugin.init(); + assertTrue( + actions.stream().anyMatch(a -> "mistral/mistral-embed".equals(a.getName())), + "Should register the mistral-embed embedder"); + } + @Test void testCustomModel() { MistralPlugin plugin = diff --git a/plugins/openai/README.md b/plugins/openai/README.md index 07e329a03..656ef953b 100644 --- a/plugins/openai/README.md +++ b/plugins/openai/README.md @@ -93,11 +93,12 @@ Genkit genkit = Genkit.builder() ## Supported Models ### Chat Models -- `gpt-5.2`, `gpt-5.1`, `gpt-5` -- `gpt-4o`, `gpt-4o-mini` -- `gpt-4-turbo`, `gpt-4`, `gpt-4-32k` -- `gpt-3.5-turbo`, `gpt-3.5-turbo-16k` -- `o1-preview`, `o1-mini` +- `gpt-5.5`, `gpt-5.5-pro` +- `gpt-5.4`, `gpt-5.4-pro`, `gpt-5.4-mini`, `gpt-5.4-nano` +- `gpt-5.2`, `gpt-5.1`, `gpt-5`, `gpt-5-mini`, `gpt-5-nano`, `gpt-5-pro` +- `o3`, `o3-pro` +- `gpt-4.1`, `gpt-4.1-mini` +- `gpt-4o`, `gpt-4o-mini`, `gpt-4-turbo`, `gpt-4`, `gpt-3.5-turbo` ### Embedding Models - `text-embedding-3-small` diff --git a/plugins/openai/src/main/java/com/google/genkit/plugins/openai/OpenAIPlugin.java b/plugins/openai/src/main/java/com/google/genkit/plugins/openai/OpenAIPlugin.java index 27a096dce..e75faff45 100644 --- a/plugins/openai/src/main/java/com/google/genkit/plugins/openai/OpenAIPlugin.java +++ b/plugins/openai/src/main/java/com/google/genkit/plugins/openai/OpenAIPlugin.java @@ -42,19 +42,33 @@ public class OpenAIPlugin implements Plugin { /** Supported GPT models. */ public static final List SUPPORTED_MODELS = Arrays.asList( + // GPT-5.5 / 5.4 family + "gpt-5.5", + "gpt-5.5-pro", + "gpt-5.4", + "gpt-5.4-pro", + "gpt-5.4-mini", + "gpt-5.4-nano", + // GPT-5.2 / 5.1 / 5 family "gpt-5.2", + "gpt-5.2-pro", "gpt-5.1", "gpt-5", + "gpt-5-mini", + "gpt-5-nano", + "gpt-5-pro", + // o-series reasoning models + "o3", + "o3-pro", + // GPT-4.1 / 4o / 4 + "gpt-4.1", + "gpt-4.1-mini", "gpt-4o", "gpt-4o-mini", "gpt-4-turbo", "gpt-4-turbo-preview", "gpt-4", - "gpt-4-32k", - "gpt-3.5-turbo", - "gpt-3.5-turbo-16k", - "o1-preview", - "o1-mini"); + "gpt-3.5-turbo"); /** Supported embedding models. */ public static final List SUPPORTED_EMBEDDING_MODELS = @@ -62,7 +76,7 @@ public class OpenAIPlugin implements Plugin { /** Supported image generation models. */ public static final List SUPPORTED_IMAGE_MODELS = - Arrays.asList("dall-e-3", "dall-e-2", "gpt-image-1"); + Arrays.asList("gpt-image-2", "gpt-image-1.5", "gpt-image-1", "dall-e-3"); private final OpenAIPluginOptions legacyOptions; private final CompatOAIPluginOptions compatOptions; diff --git a/plugins/openai/src/test/java/com/google/genkit/plugins/openai/OpenAIPluginTest.java b/plugins/openai/src/test/java/com/google/genkit/plugins/openai/OpenAIPluginTest.java index 843c85b07..e550ea3ca 100644 --- a/plugins/openai/src/test/java/com/google/genkit/plugins/openai/OpenAIPluginTest.java +++ b/plugins/openai/src/test/java/com/google/genkit/plugins/openai/OpenAIPluginTest.java @@ -97,7 +97,7 @@ void testSupportedEmbeddingModels() { void testSupportedImageModels() { assertNotNull(OpenAIPlugin.SUPPORTED_IMAGE_MODELS); assertTrue(OpenAIPlugin.SUPPORTED_IMAGE_MODELS.contains("dall-e-3")); - assertTrue(OpenAIPlugin.SUPPORTED_IMAGE_MODELS.contains("dall-e-2")); + assertTrue(OpenAIPlugin.SUPPORTED_IMAGE_MODELS.contains("gpt-image-2")); assertTrue(OpenAIPlugin.SUPPORTED_IMAGE_MODELS.contains("gpt-image-1")); } diff --git a/plugins/xai/README.md b/plugins/xai/README.md index 6c7d617e5..65bafbaaa 100644 --- a/plugins/xai/README.md +++ b/plugins/xai/README.md @@ -13,10 +13,11 @@ This plugin provides integration with XAI (x.ai / Grok) models. ## Supported Models -- `grok-4` - Latest flagship model (131K context) -- `grok-4-1-fast` - Optimized for agentic tool calling (2M context) -- `grok-3` - Previous generation (131K context) -- `grok-3-mini` - Efficient small model (131K context) +- `grok-4.3` - Latest flagship model (2M context) +- `grok-4.20-0309-reasoning` - Reasoning mode (2M context) +- `grok-4.20-0309-non-reasoning` - Non-reasoning mode (2M context) +- `grok-4.20-multi-agent-0309` - Multi-agent mode (2M context) +- `grok-build-0.1` - Agentic coding model (256K context) ## Using Custom Models @@ -56,7 +57,7 @@ Genkit genkit = Genkit.builder() // Use the model ModelResponse response = genkit.generate( GenerateOptions.builder() - .model("xai/grok-4") + .model("xai/grok-4.3") .prompt("Tell me a joke") .build() ); diff --git a/plugins/xai/src/main/java/com/google/genkit/plugins/xai/XAIPlugin.java b/plugins/xai/src/main/java/com/google/genkit/plugins/xai/XAIPlugin.java index e2b0b781c..908c7a7c3 100644 --- a/plugins/xai/src/main/java/com/google/genkit/plugins/xai/XAIPlugin.java +++ b/plugins/xai/src/main/java/com/google/genkit/plugins/xai/XAIPlugin.java @@ -40,23 +40,16 @@ public class XAIPlugin implements Plugin { /** Supported XAI models. */ public static final List SUPPORTED_MODELS = Arrays.asList( - // Latest flagship models - "grok-4", - "grok-4-1-fast", - - // Reasoning variants - "grok-4-1-fast-reasoning", - "grok-4-1-fast-non-reasoning", - "grok-4-fast-reasoning", - "grok-4-fast-non-reasoning", - - // Code model - "grok-code-fast-1", - - // Previous generation - "grok-4-0709", - "grok-3", - "grok-3-mini"); + // Latest flagship model + "grok-4.3", + + // Grok 4.20 variants + "grok-4.20-0309-reasoning", + "grok-4.20-0309-non-reasoning", + "grok-4.20-multi-agent-0309", + + // Agentic coding model + "grok-build-0.1"); private final CompatOAIPluginOptions options; private final List customModels = new ArrayList<>(); diff --git a/plugins/xai/src/test/java/com/google/genkit/plugins/xai/XAIPluginTest.java b/plugins/xai/src/test/java/com/google/genkit/plugins/xai/XAIPluginTest.java index 4b7fe0334..d656fea8c 100644 --- a/plugins/xai/src/test/java/com/google/genkit/plugins/xai/XAIPluginTest.java +++ b/plugins/xai/src/test/java/com/google/genkit/plugins/xai/XAIPluginTest.java @@ -67,10 +67,9 @@ void testCreateWithApiKey() { @Test void testSupportedModels() { assertNotNull(XAIPlugin.SUPPORTED_MODELS); - assertTrue(XAIPlugin.SUPPORTED_MODELS.contains("grok-4")); - assertTrue(XAIPlugin.SUPPORTED_MODELS.contains("grok-4-1-fast")); - assertTrue(XAIPlugin.SUPPORTED_MODELS.contains("grok-3")); - assertTrue(XAIPlugin.SUPPORTED_MODELS.contains("grok-code-fast-1")); + assertTrue(XAIPlugin.SUPPORTED_MODELS.contains("grok-4.3")); + assertTrue(XAIPlugin.SUPPORTED_MODELS.contains("grok-4.20-0309-reasoning")); + assertTrue(XAIPlugin.SUPPORTED_MODELS.contains("grok-build-0.1")); } @Test diff --git a/samples/anthropic/README.md b/samples/anthropic/README.md index afc780eb0..d0852be85 100644 --- a/samples/anthropic/README.md +++ b/samples/anthropic/README.md @@ -21,17 +21,19 @@ The Anthropic plugin supports the following Claude models: - `claude-sonnet-4-5-20250929` - Excellent balance of capability and speed - `claude-haiku-4-5-20251001` - Fast and efficient +### Claude 5 Family +- `claude-fable-5` - Most capable widely released model +- `claude-sonnet-5` - Balanced speed and intelligence + ### Claude 4 Family +- `claude-opus-4-8` - Flagship Opus +- `claude-opus-4-7` - Claude Opus 4.7 +- `claude-opus-4-6` - Claude Opus 4.6 +- `claude-sonnet-4-6` - Claude Sonnet 4.6 - `claude-opus-4-1-20250805` - Claude 4.1 Opus - `claude-opus-4-20250514` - Claude 4 Opus - `claude-sonnet-4-20250514` - Claude 4 Sonnet -### Claude 3 Family -- `claude-3-7-sonnet-20250219` - Claude 3.7 Sonnet -- `claude-3-5-haiku-20241022` - Claude 3.5 Haiku -- `claude-3-opus-20240229` - Most powerful Claude 3 model -- `claude-3-haiku-20240307` - Fastest Claude 3 model - ## Prerequisites - Java 21+ diff --git a/samples/azure-foundry/src/main/java/com/google/genkit/samples/AzureFoundrySample.java b/samples/azure-foundry/src/main/java/com/google/genkit/samples/AzureFoundrySample.java index d3f382a39..4cf381f1f 100644 --- a/samples/azure-foundry/src/main/java/com/google/genkit/samples/AzureFoundrySample.java +++ b/samples/azure-foundry/src/main/java/com/google/genkit/samples/AzureFoundrySample.java @@ -38,9 +38,9 @@ * streaming for real-time responses - Use Azure Managed Identity for authentication - Expose flows * via HTTP endpoints * - *

Supported models include: - Azure OpenAI: gpt-5-turbo, o1, o3-mini, gpt-4o, gpt-4o-mini, - * gpt-4, gpt-35-turbo - Azure Direct: MAI-DS-R1, Grok-4, Llama-3.3, DeepSeek-V3/R1, GPT-OSS - - * Partner: Claude Opus/Sonnet/Haiku 4.x + *

Supported models include: - Azure OpenAI: gpt-5.5, gpt-5.4, gpt-5, o3, gpt-4.1, gpt-4o - Azure + * Direct: Grok-4, Grok-4.1-fast, Llama-3.3, DeepSeek-V3.2, Mistral-Large-3, GPT-OSS - Partner: + * Claude Opus/Sonnet 4.x and 5 * *

To run: 1. Set AZURE_AI_FOUNDRY_ENDPOINT environment variable 2. Configure authentication (API * key or Azure credentials) 3. Ensure models are deployed in your Azure AI Foundry project 4. Run: diff --git a/samples/firebase/src/main/java/com/google/genkit/samples/firebase/FirestoreRAGSample.java b/samples/firebase/src/main/java/com/google/genkit/samples/firebase/FirestoreRAGSample.java index 1992fdd6b..bdea3ee2c 100644 --- a/samples/firebase/src/main/java/com/google/genkit/samples/firebase/FirestoreRAGSample.java +++ b/samples/firebase/src/main/java/com/google/genkit/samples/firebase/FirestoreRAGSample.java @@ -129,7 +129,7 @@ public static void main(String[] args) { true) // Auto-create database if it doesn't exist .createVectorIndexIfNotExists(true) // Auto-create vector index if it // doesn't exist - .embedderDimension(768) // text-embedding-004 outputs 768 dimensions + .embedderDimension(768) // gemini-embedding-001 outputs 768 dimensions .build()) .build()) .plugin(jetty) diff --git a/samples/google-genai/README.md b/samples/google-genai/README.md index 949330d1c..567677d77 100644 --- a/samples/google-genai/README.md +++ b/samples/google-genai/README.md @@ -119,9 +119,9 @@ The Google GenAI plugin provides access to: | Model | Description | |-------|-------------| | `googleai/gemini-2.5-flash` | Fast, efficient Gemini model | -| `googleai/gemini-1.5-pro` | Advanced reasoning capabilities | -| `googleai/gemini-1.5-flash` | Balanced speed and capability | -| `googleai/imagen-3.0-generate-002` | Image generation | +| `googleai/gemini-3.1-pro-preview` | Advanced reasoning capabilities | +| `googleai/gemini-3.5-flash` | Balanced speed and capability | +| `googleai/imagen-4.0-fast-generate-001` | Image generation | | `googleai/gemini-embedding-001` | Text embeddings | ## Code Highlights @@ -166,7 +166,7 @@ genkit.defineFlow("imageGeneration", String.class, String.class, (ctx, prompt) -> { ModelResponse response = genkit.generate( GenerateOptions.builder() - .model("googleai/imagen-3.0-generate-002") + .model("googleai/imagen-4.0-fast-generate-001") .prompt(prompt) .build()); // Save generated image to file diff --git a/samples/google-genai/src/main/java/com/google/genkit/samples/GoogleGenAIApp.java b/samples/google-genai/src/main/java/com/google/genkit/samples/GoogleGenAIApp.java index 4558b5d86..c572ecc38 100644 --- a/samples/google-genai/src/main/java/com/google/genkit/samples/GoogleGenAIApp.java +++ b/samples/google-genai/src/main/java/com/google/genkit/samples/GoogleGenAIApp.java @@ -84,6 +84,7 @@ public static void main(String[] args) throws Exception { defineImageGenerationFlow(genkit); defineTextToSpeechFlow(genkit); defineVideoGenerationFlow(genkit); + defineOmniFlow(genkit); System.out.println("Server started on http://localhost:8080"); System.out.println("Use Genkit Developer UI at http://localhost:4000 to interact with flows"); @@ -96,6 +97,10 @@ public static void main(String[] args) throws Exception { System.out.println(" - textToSpeech: Generate audio with TTS (saves to " + OUTPUT_DIR + "/)"); System.out.println( " - videoGeneration: Generate videos with Veo (saves to " + OUTPUT_DIR + "/)"); + System.out.println( + " - omniVideo: Generate + conversationally edit video with Gemini Omni (saves to " + + OUTPUT_DIR + + "/)"); System.out.println( "\nGenerated media files will be saved to: " + new File(OUTPUT_DIR).getAbsolutePath()); System.out.println("\nPress Ctrl+C to stop the server."); @@ -401,7 +406,7 @@ private static void defineVideoGenerationFlow(Genkit genkit) { ModelResponse response = genkit.generate( GenerateOptions.builder() - .model("googleai/veo-3.0-generate-001") + .model("googleai/veo-3.1-generate-preview") .prompt(prompt) .config(config) .build()); @@ -460,4 +465,88 @@ private static void defineVideoGenerationFlow(Genkit genkit) { return "No videos generated"; }); } + + /** + * Demonstrates Gemini Omni: generate a video, then conversationally edit it by passing the + * returned interaction id back as {@code previousInteractionId}. + */ + private static void defineOmniFlow(Genkit genkit) { + genkit.defineFlow( + "omniVideo", + String.class, + String.class, + (ctx, prompt) -> { + StringBuilder result = new StringBuilder(); + + // Turn 1: generate a video from the prompt. + ModelResponse first = + genkit.generate( + GenerateOptions.builder() + .model("googleai/gemini-omni-flash-preview") + .prompt(prompt) + .config( + GenerationConfig.builder() + .custom(Map.of("aspectRatio", "16:9", "duration", "10s")) + .build()) + .build()); + result.append("--- Turn 1 (generate) ---\n"); + result.append(saveVideosFromResponse(first, "omni")); + + String interactionId = + first.getCustom() != null ? (String) first.getCustom().get("interactionId") : null; + result.append("Interaction id: ").append(interactionId).append("\n"); + + // Turn 2: conversational edit that builds on the previous result. + if (interactionId != null) { + ModelResponse edited = + genkit.generate( + GenerateOptions.builder() + .model("googleai/gemini-omni-flash-preview") + .prompt("Brighten the background and add a slow push-in on the subject.") + .config( + GenerationConfig.builder() + .custom(Map.of("previousInteractionId", interactionId)) + .build()) + .build()); + result.append("\n--- Turn 2 (edit) ---\n"); + result.append(saveVideosFromResponse(edited, "omni_edited")); + } + + return result.toString(); + }); + } + + /** Saves any video media parts in a model response to disk, returning a summary string. */ + private static String saveVideosFromResponse(ModelResponse response, String prefix) { + if (response.getMessage() == null || response.getMessage().getContent() == null) { + return "No video returned.\n"; + } + StringBuilder result = new StringBuilder(); + int count = 0; + for (Part part : response.getMessage().getContent()) { + if (part.getMedia() == null) { + continue; + } + count++; + String url = part.getMedia().getUrl(); + if (url.startsWith("data:")) { + String base64Data = extractBase64FromDataUrl(url); + String filename = prefix + "_" + System.currentTimeMillis() + "_" + count + ".mp4"; + try { + result + .append("Video saved to: ") + .append(saveBase64ToFile(base64Data, filename)) + .append("\n"); + } catch (IOException e) { + result.append("Video failed to save: ").append(e.getMessage()).append("\n"); + } + } else { + result.append("Video available at: ").append(url).append("\n"); + } + } + if (count == 0) { + result.append("No video parts in response.\n"); + } + return result.toString(); + } } diff --git a/samples/groq/README.md b/samples/groq/README.md index 00c2a57cb..a4b7a6836 100644 --- a/samples/groq/README.md +++ b/samples/groq/README.md @@ -20,7 +20,8 @@ This sample demonstrates integration with Groq's ultra-fast LLM inference using - `llama-3.3-70b-versatile` - Latest Meta Llama 3.3 70B (~280 tokens/sec) - `openai/gpt-oss-120b` - OpenAI GPT-OSS 120B with reasoning (~500 tokens/sec) - `openai/gpt-oss-20b` - OpenAI GPT-OSS 20B (~1000 tokens/sec) -- `meta-llama/llama-guard-4-12b` - Content moderation (~1200 tokens/sec) +- `groq/compound` - Agentic system (web search + code execution) +- `groq/compound-mini` - Lightweight agentic system ## Prerequisites @@ -67,9 +68,9 @@ The Dev UI will be available at http://localhost:4000 | `chat` | llama-3.3-70b-versatile | Chat with most capable model | | `timeAssistant` | llama-3.3-70b-versatile | Time zone assistant with tool | | `fastStreaming` | llama-3.1-8b-instant | Ultra-fast streaming responses | -| `qualityChat` | mixtral-8x7b-32768 | High-quality chat with large context | -| `efficientChat` | gemma2-9b-it | Efficient chat | -| `realTimeQA` | llama-3.1-70b-versatile | Real-time Q&A with timing | +| `qualityChat` | openai/gpt-oss-120b | High-quality chat | +| `efficientChat` | openai/gpt-oss-20b | Efficient chat | +| `realTimeQA` | llama-3.3-70b-versatile | Real-time Q&A with timing | | `speedComparison` | multiple | Benchmark Groq's speed | ## Example API Calls diff --git a/samples/groq/src/main/java/com/google/genkit/samples/GroqSample.java b/samples/groq/src/main/java/com/google/genkit/samples/GroqSample.java index 2a9abf70d..80f217c9b 100644 --- a/samples/groq/src/main/java/com/google/genkit/samples/GroqSample.java +++ b/samples/groq/src/main/java/com/google/genkit/samples/GroqSample.java @@ -174,7 +174,7 @@ public static void main(String[] args) throws Exception { ModelResponse response = genkit.generate( GenerateOptions.builder() - .model("groq/mixtral-8x7b-32768") + .model("groq/openai/gpt-oss-120b") .system( "You are a knowledgeable assistant providing detailed, high-quality responses.") .prompt(userMessage) @@ -188,7 +188,7 @@ public static void main(String[] args) throws Exception { return response.getText(); }); - // Define an efficient chat flow using Gemma 2 + // Define an efficient chat flow using GPT-OSS 20B Flow efficientChatFlow = genkit.defineFlow( "efficientChat", @@ -198,7 +198,7 @@ public static void main(String[] args) throws Exception { ModelResponse response = genkit.generate( GenerateOptions.builder() - .model("groq/gemma2-9b-it") + .model("groq/openai/gpt-oss-20b") .system("You are an efficient, helpful assistant.") .prompt(userMessage) .config( @@ -223,7 +223,7 @@ public static void main(String[] args) throws Exception { ModelResponse response = genkit.generate( GenerateOptions.builder() - .model("groq/llama-3.1-70b-versatile") + .model("groq/llama-3.3-70b-versatile") .system( "You are a real-time Q&A assistant. Provide quick, accurate answers.") .prompt(question) @@ -288,8 +288,8 @@ public static void main(String[] args) throws Exception { System.out.println(" POST http://localhost:8080/api/flows/timeAssistant (uses tools)"); System.out.println( " POST http://localhost:8080/api/flows/fastStreaming (llama-3.1-8b ultra-fast)"); - System.out.println(" POST http://localhost:8080/api/flows/qualityChat (mixtral-8x7b)"); - System.out.println(" POST http://localhost:8080/api/flows/efficientChat (gemma2-9b)"); + System.out.println(" POST http://localhost:8080/api/flows/qualityChat (gpt-oss-120b)"); + System.out.println(" POST http://localhost:8080/api/flows/efficientChat (gpt-oss-20b)"); System.out.println(" POST http://localhost:8080/api/flows/realTimeQA (real-time Q&A)"); System.out.println( " POST http://localhost:8080/api/flows/speedComparison (benchmark Groq speed)"); diff --git a/samples/mistral/README.md b/samples/mistral/README.md index 1418dc865..abc418d6e 100644 --- a/samples/mistral/README.md +++ b/samples/mistral/README.md @@ -29,9 +29,6 @@ This sample demonstrates integration with Mistral AI models using Genkit Java. - `ministral-8b-2512` - Balanced 8B model (262K context) - `ministral-14b-2512` - Advanced 14B model (262K context) -### Vision Models -- `pixtral-large-2411` - Multimodal with vision (131K context) - ### Code Models - `codestral-2508` - Code generation specialist (256K context) - `devstral-2512` - Code-agentic model (262K context) diff --git a/samples/mistral/src/main/java/com/google/genkit/samples/MistralSample.java b/samples/mistral/src/main/java/com/google/genkit/samples/MistralSample.java index 610bd5080..700b20dc6 100644 --- a/samples/mistral/src/main/java/com/google/genkit/samples/MistralSample.java +++ b/samples/mistral/src/main/java/com/google/genkit/samples/MistralSample.java @@ -283,7 +283,7 @@ public static void main(String[] args) throws Exception { System.out.println(" POST http://localhost:8080/api/flows/quickQA (ministral-3b)"); System.out.println(" POST http://localhost:8080/api/flows/efficientChat (ministral-8b)"); System.out.println( - " POST http://localhost:8080/api/flows/creativeWriting (pixtral-large with streaming)"); + " POST http://localhost:8080/api/flows/creativeWriting (mistral-large with streaming)"); System.out.println(""); System.out.println("Example usage:"); System.out.println( diff --git a/samples/pinecone/README.md b/samples/pinecone/README.md index 4dfdcca16..9da9cc878 100644 --- a/samples/pinecone/README.md +++ b/samples/pinecone/README.md @@ -168,7 +168,7 @@ Either create the index in the Pinecone console or enable auto-creation. Vector dimension does not match index dimension ``` -Ensure your index was created with 768 dimensions to match `text-embedding-004`. +Ensure your index was created with 768 dimensions to match `gemini-embedding-001`. ### Rate limiting diff --git a/samples/xai/README.md b/samples/xai/README.md index 389c28692..4635301d6 100644 --- a/samples/xai/README.md +++ b/samples/xai/README.md @@ -10,27 +10,20 @@ This sample demonstrates integration with XAI (Grok) models using Genkit Java. - **Text Generation** - Generate text with latest Grok 4 models - **Streaming** - Real-time response streaming - **Code Generation** - Generate code with Grok 3 -- **Fast Tool Calling** - Optimized agentic workflows with Grok 4.1 Fast +- **Fast Tool Calling** - Optimized agentic workflows with Grok 4.3 ## Supported Models ### Latest Flagship -- `grok-4` - Latest flagship model (131K context) -- `grok-4-1-fast` - Optimized for agentic tool calling (2M context) +- `grok-4.3` - Latest flagship model (2M context) -### Reasoning Variants -- `grok-4-1-fast-reasoning` - Fast reasoning mode (2M context) -- `grok-4-1-fast-non-reasoning` - Fast without reasoning (2M context) -- `grok-4-fast-reasoning` - Standard fast reasoning (2M context) -- `grok-4-fast-non-reasoning` - Standard fast without reasoning (2M context) +### Grok 4.20 Variants +- `grok-4.20-0309-reasoning` - Reasoning mode (2M context) +- `grok-4.20-0309-non-reasoning` - Non-reasoning mode (2M context) +- `grok-4.20-multi-agent-0309` - Multi-agent mode (2M context) -### Code Generation -- `grok-code-fast-1` - Specialized for code generation (256K context) - -### Previous Generation -- `grok-4-0709` - July 2024 version (256K context) -- `grok-3` - Powerful previous generation (131K context) -- `grok-3-mini` - Efficient small model (131K context) +### Agentic Coding +- `grok-build-0.1` - Specialized for agentic coding (256K context) ## Prerequisites @@ -74,12 +67,12 @@ The Dev UI will be available at http://localhost:4000 | Flow | Model | Description | |------|-------|-------------| | `greeting` | - | Simple greeting flow | -| `chat` | grok-4 | Chat with latest Grok | -| `weatherAssistant` | grok-4-1-fast | Fast tool calling for weather | -| `streamingChat` | grok-4 | Streaming chat responses | -| `generateCode` | grok-3 | Code generation | -| `analyze` | grok-3-mini | Fast text analysis | -| `creativeWriting` | grok-4 | Creative writing with streaming | +| `chat` | grok-4.3 | Chat with latest Grok | +| `weatherAssistant` | grok-4.3 | Tool calling for weather | +| `streamingChat` | grok-4.3 | Streaming chat responses | +| `generateCode` | grok-build-0.1 | Agentic code generation | +| `analyze` | grok-4.20-0309-reasoning | Reasoning-based text analysis | +| `creativeWriting` | grok-4.3 | Creative writing with streaming | ## Example API Calls diff --git a/samples/xai/src/main/java/com/google/genkit/samples/XAISample.java b/samples/xai/src/main/java/com/google/genkit/samples/XAISample.java index bdd1a7293..8a1fafd37 100644 --- a/samples/xai/src/main/java/com/google/genkit/samples/XAISample.java +++ b/samples/xai/src/main/java/com/google/genkit/samples/XAISample.java @@ -66,7 +66,7 @@ public static void main(String[] args) throws Exception { ModelResponse response = genkit.generate( GenerateOptions.builder() - .model("xai/grok-4") + .model("xai/grok-4.3") .system("You are Grok, a witty and helpful AI assistant created by xAI.") .prompt(userMessage) .build()); @@ -107,7 +107,7 @@ public static void main(String[] args) throws Exception { ModelResponse response = genkit.generate( GenerateOptions.builder() - .model("xai/grok-4-1-fast") + .model("xai/grok-4.3") .system( "You are a helpful weather assistant. Use the getWeather tool to provide weather information.") .prompt(userMessage) @@ -133,7 +133,7 @@ public static void main(String[] args) throws Exception { ModelResponse response = genkit.generateStream( GenerateOptions.builder() - .model("xai/grok-4") + .model("xai/grok-4.3") .system("You are Grok, providing detailed and engaging responses.") .prompt(userMessage) .config(GenerationConfig.builder().maxOutputTokens(1000).build()) @@ -160,7 +160,7 @@ public static void main(String[] args) throws Exception { ModelResponse response = genkit.generate( GenerateOptions.builder() - .model("xai/grok-3") + .model("xai/grok-build-0.1") .system( "You are an expert programmer. Write clean, well-documented code with helpful comments.") .prompt(prompt) @@ -184,7 +184,7 @@ public static void main(String[] args) throws Exception { ModelResponse response = genkit.generate( GenerateOptions.builder() - .model("xai/grok-3-mini") + .model("xai/grok-4.20-0309-reasoning") .system( "You are an analytical expert. Provide detailed, insightful analysis.") .prompt("Please analyze the following:\n\n" + text) @@ -214,7 +214,7 @@ public static void main(String[] args) throws Exception { ModelResponse response = genkit.generateStream( GenerateOptions.builder() - .model("xai/grok-4") + .model("xai/grok-4.3") .system( "You are a creative writer with a unique perspective. Write engaging, imaginative content.") .prompt(prompt) From f3be19cf737595df6b4afc45631af1e16da1d7ba Mon Sep 17 00:00:00 2001 From: xavidop Date: Mon, 6 Jul 2026 19:36:40 +0200 Subject: [PATCH 5/6] feat: mistral update --- plugins/mistral/README.md | 2 +- .../java/com/google/genkit/plugins/mistral/MistralPlugin.java | 2 -- .../com/google/genkit/plugins/mistral/MistralPluginTest.java | 2 +- 3 files changed, 2 insertions(+), 4 deletions(-) diff --git a/plugins/mistral/README.md b/plugins/mistral/README.md index 6f06009ff..7d33fbed7 100644 --- a/plugins/mistral/README.md +++ b/plugins/mistral/README.md @@ -16,7 +16,7 @@ This plugin provides integration with Mistral AI models. - `mistral-large-2512` - Flagship model (256K context) - `mistral-medium-2604` - Mistral Medium 3.5 (128K context) - `mistral-small-2603` - Mistral Small 4 (128K context) -- `magistral-medium-2509`, `magistral-small-2509` - Reasoning models +- `magistral-medium-2509` - Reasoning model - `ministral-3b-2512`, `ministral-8b-2512`, `ministral-14b-2512` - Compact models - `codestral-2508` - Code generation specialist (256K context) - `devstral-2512` - Developer/agentic coding model diff --git a/plugins/mistral/src/main/java/com/google/genkit/plugins/mistral/MistralPlugin.java b/plugins/mistral/src/main/java/com/google/genkit/plugins/mistral/MistralPlugin.java index 6d4ecd838..2c8990196 100644 --- a/plugins/mistral/src/main/java/com/google/genkit/plugins/mistral/MistralPlugin.java +++ b/plugins/mistral/src/main/java/com/google/genkit/plugins/mistral/MistralPlugin.java @@ -46,11 +46,9 @@ public class MistralPlugin implements Plugin { "mistral-medium-2604", "mistral-medium-2508", "mistral-small-2603", - "mistral-small-2506", // Reasoning models "magistral-medium-2509", - "magistral-small-2509", // Compact models "ministral-3b-2512", diff --git a/plugins/mistral/src/test/java/com/google/genkit/plugins/mistral/MistralPluginTest.java b/plugins/mistral/src/test/java/com/google/genkit/plugins/mistral/MistralPluginTest.java index d7feab971..63d9303f3 100644 --- a/plugins/mistral/src/test/java/com/google/genkit/plugins/mistral/MistralPluginTest.java +++ b/plugins/mistral/src/test/java/com/google/genkit/plugins/mistral/MistralPluginTest.java @@ -89,7 +89,7 @@ void testInitializesActions() { void testSupportedModels() { assertNotNull(MistralPlugin.SUPPORTED_MODELS); assertTrue(MistralPlugin.SUPPORTED_MODELS.contains("mistral-large-2512")); - assertTrue(MistralPlugin.SUPPORTED_MODELS.contains("mistral-small-2506")); + assertTrue(MistralPlugin.SUPPORTED_MODELS.contains("mistral-small-2603")); } @Test From a382d65d76ccd641759565cf212435fc40f58b0a Mon Sep 17 00:00:00 2001 From: xavidop Date: Mon, 6 Jul 2026 19:50:18 +0200 Subject: [PATCH 6/6] fix: gemini feedback --- .../plugins/awsbedrock/AwsBedrockEmbedder.java | 9 +++------ .../genkit/plugins/chroma/ChromaVectorStore.java | 16 ++++++++++++++-- .../plugins/compatoai/CompatOAIEmbedder.java | 11 +++-------- .../genkit/plugins/milvus/MilvusVectorStore.java | 16 ++++++++++++++-- .../genkit/plugins/mongodb/MongoVectorStore.java | 15 +++++++++++++-- .../mongodb/session/MongoSessionStore.java | 2 +- .../genkit/plugins/qdrant/QdrantVectorStore.java | 16 ++++++++++++++-- 7 files changed, 62 insertions(+), 23 deletions(-) diff --git a/plugins/aws-bedrock/src/main/java/com/google/genkit/plugins/awsbedrock/AwsBedrockEmbedder.java b/plugins/aws-bedrock/src/main/java/com/google/genkit/plugins/awsbedrock/AwsBedrockEmbedder.java index 4b884f637..b6faf3fd2 100644 --- a/plugins/aws-bedrock/src/main/java/com/google/genkit/plugins/awsbedrock/AwsBedrockEmbedder.java +++ b/plugins/aws-bedrock/src/main/java/com/google/genkit/plugins/awsbedrock/AwsBedrockEmbedder.java @@ -35,8 +35,6 @@ import okhttp3.OkHttpClient; import okhttp3.Request; import okhttp3.Response; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; /** * AWS Bedrock embedder implementation for Genkit. @@ -47,8 +45,6 @@ */ public class AwsBedrockEmbedder extends Embedder { - private static final Logger logger = LoggerFactory.getLogger(AwsBedrockEmbedder.class); - private final String modelId; private final AwsBedrockPluginOptions options; private final OkHttpClient client; @@ -109,8 +105,9 @@ public EmbedResponse run(ActionContext context, EmbedRequest request) { for (Document doc : request.getDocuments()) { String text = doc.text(); if (text == null || text.isEmpty()) { - logger.warn("Document has empty text, skipping"); - continue; + // Throw rather than skip: skipping would make the embeddings list shorter than the + // documents list, breaking the 1-to-1 index mapping the caller relies on. + throw new GenkitException("Document text cannot be null or empty"); } embeddings.add(embedOne(text)); } diff --git a/plugins/chroma/src/main/java/com/google/genkit/plugins/chroma/ChromaVectorStore.java b/plugins/chroma/src/main/java/com/google/genkit/plugins/chroma/ChromaVectorStore.java index ea1700cdd..daef61438 100644 --- a/plugins/chroma/src/main/java/com/google/genkit/plugins/chroma/ChromaVectorStore.java +++ b/plugins/chroma/src/main/java/com/google/genkit/plugins/chroma/ChromaVectorStore.java @@ -200,15 +200,27 @@ public IndexerResponse index(ActionContext context, IndexerRequest request) { } String id = ensureCollection(); + // Batch-generate embeddings for all documents in a single embedder call. + EmbedResponse embedResponse = embedder.run(context, new EmbedRequest(documents)); + if (embedResponse.getEmbeddings() == null + || embedResponse.getEmbeddings().size() != documents.size()) { + throw new RuntimeException("Failed to generate embeddings: mismatched output size"); + } + ObjectNode body = MAPPER.createObjectNode(); ArrayNode ids = body.putArray("ids"); ArrayNode embeddings = body.putArray("embeddings"); ArrayNode contents = body.putArray("documents"); ArrayNode metadatas = body.putArray("metadatas"); - for (Document doc : documents) { + for (int i = 0; i < documents.size(); i++) { + Document doc = documents.get(i); String content = doc.text() != null ? doc.text() : ""; - List embedding = generateEmbedding(context, content); + float[] values = embedResponse.getEmbeddings().get(i).getValues(); + List embedding = new ArrayList<>(values.length); + for (float v : values) { + embedding.add(v); + } ids.add(getOrGenerateId(doc)); embeddings.add(floatsToArray(embedding)); contents.add(content); diff --git a/plugins/compat-oai/src/main/java/com/google/genkit/plugins/compatoai/CompatOAIEmbedder.java b/plugins/compat-oai/src/main/java/com/google/genkit/plugins/compatoai/CompatOAIEmbedder.java index 5c2336f4a..79b76581f 100644 --- a/plugins/compat-oai/src/main/java/com/google/genkit/plugins/compatoai/CompatOAIEmbedder.java +++ b/plugins/compat-oai/src/main/java/com/google/genkit/plugins/compatoai/CompatOAIEmbedder.java @@ -34,8 +34,6 @@ import java.util.List; import java.util.concurrent.TimeUnit; import okhttp3.*; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; /** * Embedder implementation for any provider that exposes an OpenAI-compatible {@code /embeddings} @@ -46,7 +44,6 @@ */ public class CompatOAIEmbedder extends Embedder { - private static final Logger logger = LoggerFactory.getLogger(CompatOAIEmbedder.class); private static final MediaType JSON_MEDIA_TYPE = MediaType.parse("application/json"); private final String apiModelName; @@ -128,14 +125,12 @@ private EmbedResponse callApi(EmbedRequest request) throws IOException { for (Document doc : request.getDocuments()) { String text = doc.text(); if (text == null || text.isEmpty()) { - logger.warn("Document has empty text, skipping"); - continue; + // Throw rather than skip: skipping would make the returned embeddings list shorter than the + // documents list, breaking the 1-to-1 index mapping the caller relies on. + throw new GenkitException("Document text cannot be null or empty"); } input.add(text); } - if (input.isEmpty()) { - throw new GenkitException("No valid documents to embed - all documents had empty text"); - } Request.Builder requestBuilder = new Request.Builder() diff --git a/plugins/milvus/src/main/java/com/google/genkit/plugins/milvus/MilvusVectorStore.java b/plugins/milvus/src/main/java/com/google/genkit/plugins/milvus/MilvusVectorStore.java index 222cba462..c5c2fc063 100644 --- a/plugins/milvus/src/main/java/com/google/genkit/plugins/milvus/MilvusVectorStore.java +++ b/plugins/milvus/src/main/java/com/google/genkit/plugins/milvus/MilvusVectorStore.java @@ -189,12 +189,24 @@ public IndexerResponse index(ActionContext context, IndexerRequest request) { return new IndexerResponse(); } + // Batch-generate embeddings for all documents in a single embedder call. + EmbedResponse embedResponse = embedder.run(context, new EmbedRequest(documents)); + if (embedResponse.getEmbeddings() == null + || embedResponse.getEmbeddings().size() != documents.size()) { + throw new RuntimeException("Failed to generate embeddings: mismatched output size"); + } + ObjectNode body = MAPPER.createObjectNode(); body.put("collectionName", config.getCollectionName()); ArrayNode data = body.putArray("data"); - for (Document doc : documents) { + for (int i = 0; i < documents.size(); i++) { + Document doc = documents.get(i); String content = doc.text() != null ? doc.text() : ""; - List embedding = generateEmbedding(context, content); + float[] values = embedResponse.getEmbeddings().get(i).getValues(); + List embedding = new ArrayList<>(values.length); + for (float v : values) { + embedding.add(v); + } ObjectNode entity = data.addObject(); entity.set(VECTOR_FIELD, floatsToArray(embedding)); diff --git a/plugins/mongodb/src/main/java/com/google/genkit/plugins/mongodb/MongoVectorStore.java b/plugins/mongodb/src/main/java/com/google/genkit/plugins/mongodb/MongoVectorStore.java index 431db234f..4dc03ed1a 100644 --- a/plugins/mongodb/src/main/java/com/google/genkit/plugins/mongodb/MongoVectorStore.java +++ b/plugins/mongodb/src/main/java/com/google/genkit/plugins/mongodb/MongoVectorStore.java @@ -291,10 +291,21 @@ public IndexerResponse index(ActionContext context, IndexerRequest request) { logger.warn("No documents to index"); return new IndexerResponse(); } + // Batch-generate embeddings for all documents in a single embedder call. + EmbedResponse embedResponse = embedder.run(context, new EmbedRequest(documents)); + if (embedResponse.getEmbeddings() == null + || embedResponse.getEmbeddings().size() != documents.size()) { + throw new RuntimeException("Failed to generate embeddings: mismatched output size"); + } int indexed = 0; - for (Document doc : documents) { + for (int i = 0; i < documents.size(); i++) { + Document doc = documents.get(i); String content = doc.text() != null ? doc.text() : ""; - List embedding = generateEmbedding(context, content); + float[] values = embedResponse.getEmbeddings().get(i).getValues(); + List embedding = new ArrayList<>(values.length); + for (float v : values) { + embedding.add((double) v); + } String id = getOrGenerateId(doc); org.bson.Document stored = new org.bson.Document("_id", id); diff --git a/plugins/mongodb/src/main/java/com/google/genkit/plugins/mongodb/session/MongoSessionStore.java b/plugins/mongodb/src/main/java/com/google/genkit/plugins/mongodb/session/MongoSessionStore.java index be788130e..bb36fafd4 100644 --- a/plugins/mongodb/src/main/java/com/google/genkit/plugins/mongodb/session/MongoSessionStore.java +++ b/plugins/mongodb/src/main/java/com/google/genkit/plugins/mongodb/session/MongoSessionStore.java @@ -623,7 +623,7 @@ private Row readRow(String prefix, String id) { payload.remove("pk"); payload.remove("version"); try { - return new Row((ObjectNode) MAPPER.readTree(payload.toJson()), version); + return new Row((ObjectNode) MAPPER.valueToTree(payload), version); } catch (Exception e) { throw new GenkitException("Failed to read session document " + id + ": " + e.getMessage(), e); } diff --git a/plugins/qdrant/src/main/java/com/google/genkit/plugins/qdrant/QdrantVectorStore.java b/plugins/qdrant/src/main/java/com/google/genkit/plugins/qdrant/QdrantVectorStore.java index 6e7dfc73a..e0701c3da 100644 --- a/plugins/qdrant/src/main/java/com/google/genkit/plugins/qdrant/QdrantVectorStore.java +++ b/plugins/qdrant/src/main/java/com/google/genkit/plugins/qdrant/QdrantVectorStore.java @@ -187,11 +187,23 @@ public IndexerResponse index(ActionContext context, IndexerRequest request) { return new IndexerResponse(); } + // Batch-generate embeddings for all documents in a single embedder call. + EmbedResponse embedResponse = embedder.run(context, new EmbedRequest(documents)); + if (embedResponse.getEmbeddings() == null + || embedResponse.getEmbeddings().size() != documents.size()) { + throw new RuntimeException("Failed to generate embeddings: mismatched output size"); + } + ObjectNode body = MAPPER.createObjectNode(); ArrayNode points = body.putArray("points"); - for (Document doc : documents) { + for (int i = 0; i < documents.size(); i++) { + Document doc = documents.get(i); String content = doc.text() != null ? doc.text() : ""; - List embedding = generateEmbedding(context, content); + float[] values = embedResponse.getEmbeddings().get(i).getValues(); + List embedding = new ArrayList<>(values.length); + for (float v : values) { + embedding.add(v); + } ObjectNode point = points.addObject(); point.put("id", getOrGenerateId(doc));