From 55b0693e6bbad650e624ad79739cd3e389528607 Mon Sep 17 00:00:00 2001
From: Ivan Despot <66276597+g-despot@users.noreply.github.com>
Date: Fri, 14 Aug 2026 11:07:14 +0200
Subject: [PATCH 01/14] fix: Multi2VecGoogleGemini emitted a module name no
server has
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Multi2VecGoogleGemini declared [Vectorizer("multi2vec-google-gemini")]. No
such module exists: modules/multi2vec-google/module.go names the module
"multi2vec-google" with the single alias "multi2vec-palm", and grepping the
server for "multi2vec-google-gemini" returns nothing. A live 1.39.0 rejects
it outright:
422 {"error":[{"message":"target vector \"default\": vectorizer: no
module with name \"multi2vec-google-gemini\" present"}]}
So the factory could not create a collection at all.
Gemini is reached through the one Google multimodal module by pointing
apiEndpoint at generativelanguage.googleapis.com — what python does with a
single _Multi2VecGoogleConfig and a switched endpoint, and what this client
already does on the text side in Text2VecGoogleGemini. Multi2VecGoogle grows
that ApiEndpoint property and VectorizerFactory.Multi2VecGoogleGemini stays
as the ergonomic entry point, now returning a Multi2VecGoogle.
ProjectId and Location drop `required` and become nullable, matching
python's Optional[str]. The Gemini API is scoped to neither, so both are
null and the REST serializer omits them. This also unbreaks readback: a
required member absent from the server's JSON makes System.Text.Json throw,
so a Gemini config could not have round-tripped even once creation worked.
Folds in the related gap: the Gemini path had neither Dimensions nor
VectorizeCollectionName, both of which Multi2VecGoogle has and python passes.
The Vertex overloads gain an apiEndpoint parameter, which the repo's own
WEAVIATE002 analyzer requires once the property exists, and which mirrors
Text2VecGoogle.
Source-breaking for anyone binding the record type, so Multi2VecGoogleGemini
survives as an [Obsolete] shim over Multi2VecGoogle following the
Multi2VecPalm/Text2VecPalm precedent. It deliberately carries no [Vectorizer]
attribute: VectorizerRegistry keys types by identifier and last write wins,
so a second type claiming "multi2vec-palm" would make deserialization of
every Google multimodal config depend on reflection order.
Tests: the two existing Gemini unit cases now pin the module name, the
endpoint and the absent Vertex fields; the string-array case carries the new
dimensions/vectorizeCollectionName and the weighted case stays without them
so omit-when-unset stays covered. New integration coverage creates both a
Gemini and a Vertex collection against a real server and asserts the round
trip; a RequireModule gate skips when the module is absent, and CI now
enables multi2vec-google so it runs there.
---
ci/docker-compose.yml | 2 +-
.../Integration/TestVectorizers.cs | 101 ++++++++++++++++++
.../Integration/_Integration.cs | 14 +++
.../Unit/TestVectorizers.cs | 24 ++++-
.../Configure/VectorizerFactory.cs | 50 +++++++--
src/Weaviate.Client/Models/Vectorizer.cs | 81 +++++---------
src/Weaviate.Client/PublicAPI.Unshipped.txt | 30 ++++++
7 files changed, 238 insertions(+), 64 deletions(-)
create mode 100644 src/Weaviate.Client.Tests/Integration/TestVectorizers.cs
diff --git a/ci/docker-compose.yml b/ci/docker-compose.yml
index b197578c..20485dfb 100644
--- a/ci/docker-compose.yml
+++ b/ci/docker-compose.yml
@@ -22,7 +22,7 @@ services:
QUERY_DEFAULTS_LIMIT: 25
AUTHENTICATION_ANONYMOUS_ACCESS_ENABLED: 'true'
PERSISTENCE_DATA_PATH: '/var/lib/weaviate'
- ENABLE_MODULES: text2vec-transformers,text2vec-cohere,backup-filesystem,generative-dummy,generative-anyscale,reranker-dummy,reranker-cohere,text2vec-ollama,generative-ollama
+ ENABLE_MODULES: text2vec-transformers,text2vec-cohere,backup-filesystem,generative-dummy,generative-anyscale,reranker-dummy,reranker-cohere,text2vec-ollama,generative-ollama,multi2vec-google
BACKUP_FILESYSTEM_PATH: "/tmp/backups"
EXPORT_ENABLED: 'true'
EXPORT_DEFAULT_PATH: "/tmp/exports"
diff --git a/src/Weaviate.Client.Tests/Integration/TestVectorizers.cs b/src/Weaviate.Client.Tests/Integration/TestVectorizers.cs
new file mode 100644
index 00000000..00083d07
--- /dev/null
+++ b/src/Weaviate.Client.Tests/Integration/TestVectorizers.cs
@@ -0,0 +1,101 @@
+namespace Weaviate.Client.Tests.Integration;
+
+using Weaviate.Client.Models;
+
+///
+/// The vectorizer module tests class. Covers vectorizer configurations end to end, i.e. that the
+/// module name and settings the client emits are ones a real server accepts.
+///
+///
+public class TestVectorizers : IntegrationTests
+{
+ ///
+ /// Tests that the Google Gemini multimodal factory produces a collection the server accepts.
+ /// This is the whole point of the factory and it used to be impossible: the config emitted
+ /// the module name multi2vec-google-gemini, which no Weaviate build provides, so
+ /// creation failed with no module with name "multi2vec-google-gemini" present. Gemini
+ /// is reached through multi2vec-google (wire name multi2vec-palm) by pointing
+ /// apiEndpoint at the generative-language host, so the round trip must show that
+ /// module, that endpoint, and no Vertex-only project id or location.
+ ///
+ [Fact]
+ public async Task Test_Multi2VecGoogleGemini_Creates_Collection()
+ {
+ RequireModule("multi2vec-google");
+
+ var collection = await CollectionFactory(
+ name: "TestMulti2VecGoogleGemini",
+ properties: [Property.Text("text"), Property.Blob("image")],
+ vectorConfig: Configure.Vector(
+ "default",
+ v =>
+ v.Multi2VecGoogleGemini(
+ imageFields: ["image"],
+ textFields: ["text"],
+ dimensions: 512,
+ vectorizeCollectionName: false
+ )
+ )
+ );
+
+ var config = await collection.Config.Get(
+ cancellationToken: TestContext.Current.CancellationToken
+ );
+
+ Assert.NotNull(config);
+ var vectorizer = config.VectorConfig["default"].Vectorizer;
+ var google = Assert.IsType(vectorizer);
+
+ Assert.Equal("multi2vec-palm", google.Identifier);
+ Assert.Equal("generativelanguage.googleapis.com", google.ApiEndpoint);
+ Assert.Equal(512, google.Dimensions);
+ Assert.False(google.VectorizeCollectionName);
+ Assert.NotNull(google.ImageFields);
+ Assert.Equal(["image"], google.ImageFields);
+ Assert.NotNull(google.TextFields);
+ Assert.Equal(["text"], google.TextFields);
+ // Vertex-only settings: the Gemini API has neither, and the server must not echo them.
+ Assert.Null(google.ProjectId);
+ Assert.Null(google.Location);
+ }
+
+ ///
+ /// Tests that the Vertex AI multimodal factory still produces a collection the server
+ /// accepts, and that project id and location survive the round trip. Kept alongside the
+ /// Gemini case because both now share one config type: a regression that dropped either
+ /// field would otherwise only show up on the Vertex path.
+ ///
+ [Fact]
+ public async Task Test_Multi2VecGoogle_Vertex_Creates_Collection()
+ {
+ RequireModule("multi2vec-google");
+
+ var collection = await CollectionFactory(
+ name: "TestMulti2VecGoogleVertex",
+ properties: [Property.Text("text"), Property.Blob("image")],
+ vectorConfig: Configure.Vector(
+ "default",
+ v =>
+ v.Multi2VecGoogle(
+ projectId: "my-project",
+ location: "us-central1",
+ imageFields: ["image"],
+ textFields: ["text"]
+ )
+ )
+ );
+
+ var config = await collection.Config.Get(
+ cancellationToken: TestContext.Current.CancellationToken
+ );
+
+ Assert.NotNull(config);
+ var vectorizer = config.VectorConfig["default"].Vectorizer;
+ var google = Assert.IsType(vectorizer);
+
+ Assert.Equal("multi2vec-palm", google.Identifier);
+ Assert.Equal("my-project", google.ProjectId);
+ Assert.Equal("us-central1", google.Location);
+ Assert.Null(google.ApiEndpoint);
+ }
+}
diff --git a/src/Weaviate.Client.Tests/Integration/_Integration.cs b/src/Weaviate.Client.Tests/Integration/_Integration.cs
index 600927fc..2753b840 100644
--- a/src/Weaviate.Client.Tests/Integration/_Integration.cs
+++ b/src/Weaviate.Client.Tests/Integration/_Integration.cs
@@ -418,6 +418,20 @@ protected bool ServerVersionIsInRange(string minimumVersion, string? maximumVers
return VersionIsInRange(_weaviate.WeaviateVersion, minimumVersion, maximumVersion);
}
+ ///
+ /// Skips the test unless the connected server has the named module enabled.
+ /// A module the server does not load cannot be named in a collection config at all, so
+ /// tests that create such a collection are meaningless — not failing — without it.
+ ///
+ /// The module name, e.g. multi2vec-google.
+ protected void RequireModule(string moduleName)
+ {
+ if (_weaviate.Meta?.Modules.ContainsKey(moduleName) != true)
+ {
+ Assert.Skip($"Weaviate module '{moduleName}' is not enabled on the test server.");
+ }
+ }
+
///
/// Requires the version using the specified minimum version
///
diff --git a/src/Weaviate.Client.Tests/Unit/TestVectorizers.cs b/src/Weaviate.Client.Tests/Unit/TestVectorizers.cs
index 33a74b33..e39dee09 100644
--- a/src/Weaviate.Client.Tests/Unit/TestVectorizers.cs
+++ b/src/Weaviate.Client.Tests/Unit/TestVectorizers.cs
@@ -347,6 +347,10 @@ public void Test_Multi2VecGoogle_Serializes_AudioFields_WeightedFields()
///
/// Tests that Multi2VecGoogleGemini maps each string-array modality to its own key, and
/// that the unweighted overload emits no weights object.
+ /// Also pins the module name: there is no multi2vec-google-gemini module on any
+ /// server, so the Gemini factory must emit the multi2vec-google module (under its
+ /// multi2vec-palm wire name) and select Gemini with apiEndpoint instead —
+ /// and must send neither projectId nor location, which are Vertex-only.
///
[Fact]
[System.Diagnostics.CodeAnalysis.SuppressMessage(
@@ -364,7 +368,9 @@ public void Test_Multi2VecGoogleGemini_Serializes_AudioFields_StringArray()
imageFields: new[] { "image" },
textFields: new[] { "text" },
videoFields: new[] { "video" },
- audioFields: new[] { "audio" }
+ audioFields: new[] { "audio" },
+ dimensions: 512,
+ vectorizeCollectionName: false
)
);
@@ -380,10 +386,20 @@ public void Test_Multi2VecGoogleGemini_Serializes_AudioFields_StringArray()
);
// Assert
+ Assert.Contains("\"multi2vec-palm\"", json);
+ Assert.DoesNotContain("multi2vec-google-gemini", json);
+ Assert.Contains("\"apiEndpoint\":\"generativelanguage.googleapis.com\"", json);
+ // Vertex-only settings the Gemini API has no equivalent of. This test serializes with a
+ // bare options object, so nulls are written out; the REST client's options drop them, so
+ // neither key reaches the wire.
+ Assert.Contains("\"projectId\":null", json);
+ Assert.Contains("\"location\":null", json);
Assert.Contains("\"imageFields\":[\"image\"]", json);
Assert.Contains("\"textFields\":[\"text\"]", json);
Assert.Contains("\"videoFields\":[\"video\"]", json);
Assert.Contains("\"audioFields\":[\"audio\"]", json);
+ Assert.Contains("\"dimensions\":512", json);
+ Assert.Contains("\"vectorizeClassName\":false", json);
Assert.DoesNotContain("\"weights\"", json);
}
@@ -430,6 +446,9 @@ public void Test_Multi2VecGoogleGemini_Serializes_AudioFields_WeightedFields()
);
// Assert
+ Assert.Contains("\"multi2vec-palm\"", json);
+ Assert.DoesNotContain("multi2vec-google-gemini", json);
+ Assert.Contains("\"apiEndpoint\":\"generativelanguage.googleapis.com\"", json);
Assert.Contains("\"imageFields\":[\"image\",\"thumbnail\"]", json);
Assert.Contains("\"textFields\":[\"text\"]", json);
Assert.Contains("\"videoFields\":[\"video\",\"clip\"]", json);
@@ -439,6 +458,9 @@ public void Test_Multi2VecGoogleGemini_Serializes_AudioFields_WeightedFields()
+ "\"textFields\":[0.23],\"videoFields\":[0.33,0.34]}",
json
);
+ // Left unset by this case, so the omit-when-null path stays covered.
+ Assert.Contains("\"dimensions\":null", json);
+ Assert.Contains("\"vectorizeClassName\":null", json);
Assert.DoesNotContain("depthFields", json);
Assert.DoesNotContain("imuFields", json);
Assert.DoesNotContain("thermalFields", json);
diff --git a/src/Weaviate.Client/Configure/VectorizerFactory.cs b/src/Weaviate.Client/Configure/VectorizerFactory.cs
index bef93a37..2075a51c 100644
--- a/src/Weaviate.Client/Configure/VectorizerFactory.cs
+++ b/src/Weaviate.Client/Configure/VectorizerFactory.cs
@@ -321,6 +321,7 @@ public VectorizerConfig Multi2VecBind(
/// The model
/// The dimensions
/// The vectorize collection name
+ /// The api endpoint
/// The vectorizer config
public VectorizerConfig Multi2VecGoogle(
string projectId,
@@ -332,12 +333,14 @@ public VectorizerConfig Multi2VecGoogle(
int? videoIntervalSeconds = null,
string? model = null,
int? dimensions = null,
- bool? vectorizeCollectionName = null
+ bool? vectorizeCollectionName = null,
+ string? apiEndpoint = null
) =>
new Multi2VecGoogle
{
ProjectId = projectId,
Location = location,
+ ApiEndpoint = apiEndpoint,
ImageFields = ModalityFields.OrNull(imageFields),
TextFields = ModalityFields.OrNull(textFields),
VideoFields = ModalityFields.OrNull(videoFields),
@@ -371,6 +374,7 @@ public VectorizerConfig Multi2VecGoogle(
/// The model
/// The dimensions
/// The vectorize collection name
+ /// The api endpoint
/// The vectorizer config
public VectorizerConfig Multi2VecGoogle(
string projectId,
@@ -382,12 +386,14 @@ public VectorizerConfig Multi2VecGoogle(
int? videoIntervalSeconds = null,
string? model = null,
int? dimensions = null,
- bool? vectorizeCollectionName = null
+ bool? vectorizeCollectionName = null,
+ string? apiEndpoint = null
) =>
new Multi2VecGoogle
{
ProjectId = projectId,
Location = location,
+ ApiEndpoint = apiEndpoint,
ImageFields = ModalityFields.OrNull(imageFields),
TextFields = ModalityFields.OrNull(textFields),
VideoFields = ModalityFields.OrNull(videoFields),
@@ -399,7 +405,9 @@ public VectorizerConfig Multi2VecGoogle(
};
///
- /// Multi2Vec Google Gemini configuration (using Google AI Studio/Gemini API)
+ /// Multi2Vec Google Gemini configuration (using Google AI Studio/Gemini API).
+ /// Emits the multi2vec-google module with the Gemini API endpoint; there is no
+ /// separate Gemini module, so no project id or location is sent.
///
/// The image fields
/// The text fields
@@ -408,6 +416,8 @@ public VectorizerConfig Multi2VecGoogle(
/// The API endpoint
/// The video interval seconds
/// The model
+ /// The dimensions
+ /// The vectorize collection name
/// The vectorizer config
public VectorizerConfig Multi2VecGoogleGemini(
WeightedFields imageFields,
@@ -416,17 +426,25 @@ public VectorizerConfig Multi2VecGoogleGemini(
WeightedFields audioFields,
string? apiEndpoint = null,
int? videoIntervalSeconds = null,
- string? model = null
+ string? model = null,
+ int? dimensions = null,
+ bool? vectorizeCollectionName = null
) =>
- new Multi2VecGoogleGemini
+ new Multi2VecGoogle
{
+ // The Gemini API is not scoped to a Vertex AI project or region; left null so both
+ // are omitted on the wire.
+ ProjectId = null,
+ Location = null,
ApiEndpoint = apiEndpoint ?? "generativelanguage.googleapis.com",
ImageFields = ModalityFields.OrNull(imageFields),
TextFields = ModalityFields.OrNull(textFields),
VideoFields = ModalityFields.OrNull(videoFields),
AudioFields = ModalityFields.OrNull(audioFields),
VideoIntervalSeconds = videoIntervalSeconds,
- Model = model,
+ ModelId = model,
+ Dimensions = dimensions,
+ VectorizeCollectionName = vectorizeCollectionName,
// Named arguments are mandatory here: FromWeightedFields declares seven optional
// modalities in the order image, text, audio, depth, imu, thermal, video, so the
// positional call this replaced filed video weights under audio and audio weights
@@ -440,7 +458,9 @@ public VectorizerConfig Multi2VecGoogleGemini(
};
///
- /// Multi2Vec Google Gemini configuration (using Google AI Studio/Gemini API)
+ /// Multi2Vec Google Gemini configuration (using Google AI Studio/Gemini API).
+ /// Emits the multi2vec-google module with the Gemini API endpoint; there is no
+ /// separate Gemini module, so no project id or location is sent.
///
/// The image fields
/// The text fields
@@ -449,6 +469,8 @@ public VectorizerConfig Multi2VecGoogleGemini(
/// The API endpoint
/// The video interval seconds
/// The model
+ /// The dimensions
+ /// The vectorize collection name
/// The vectorizer config
public VectorizerConfig Multi2VecGoogleGemini(
string[]? imageFields = null,
@@ -457,17 +479,25 @@ public VectorizerConfig Multi2VecGoogleGemini(
string[]? audioFields = null,
string? apiEndpoint = null,
int? videoIntervalSeconds = null,
- string? model = null
+ string? model = null,
+ int? dimensions = null,
+ bool? vectorizeCollectionName = null
) =>
- new Multi2VecGoogleGemini
+ new Multi2VecGoogle
{
+ // The Gemini API is not scoped to a Vertex AI project or region; left null so both
+ // are omitted on the wire.
+ ProjectId = null,
+ Location = null,
ApiEndpoint = apiEndpoint ?? "generativelanguage.googleapis.com",
ImageFields = ModalityFields.OrNull(imageFields),
TextFields = ModalityFields.OrNull(textFields),
VideoFields = ModalityFields.OrNull(videoFields),
AudioFields = ModalityFields.OrNull(audioFields),
VideoIntervalSeconds = videoIntervalSeconds,
- Model = model,
+ ModelId = model,
+ Dimensions = dimensions,
+ VectorizeCollectionName = vectorizeCollectionName,
};
///
diff --git a/src/Weaviate.Client/Models/Vectorizer.cs b/src/Weaviate.Client/Models/Vectorizer.cs
index cf66c1bf..a6a8c771 100644
--- a/src/Weaviate.Client/Models/Vectorizer.cs
+++ b/src/Weaviate.Client/Models/Vectorizer.cs
@@ -380,14 +380,25 @@ public record Multi2VecGoogle : VectorizerConfig
internal Multi2VecGoogle() { }
///
- /// Gets or sets the value of the project id
+ /// Gets or sets the Google Cloud project id.
+ /// Required by Vertex AI; omitted when null, as it is for the Gemini
+ /// (generative-language) API, which is not scoped to a project.
///
- public required string ProjectId { get; set; }
+ public string? ProjectId { get; set; } = null;
///
- /// Gets or sets the value of the location
+ /// Gets or sets the Google Vertex AI region.
+ /// Required by Vertex AI; omitted when null, as it is for the Gemini
+ /// (generative-language) API, which has no region.
///
- public required string Location { get; set; }
+ public string? Location { get; set; } = null;
+
+ ///
+ /// Gets or sets the value of the api endpoint.
+ /// Set to generativelanguage.googleapis.com to call the Gemini API instead of
+ /// Vertex AI; when omitted the server applies its default.
+ ///
+ public string? ApiEndpoint { get; set; } = null;
///
/// Gets or sets the value of the image fields
@@ -452,61 +463,27 @@ internal Multi2VecPalm() { }
}
///
- /// The configuration for multi-media vectorization using the Google Gemini module.
- /// See the documentation for detailed usage.
+ /// Deprecated. Use Multi2VecGoogle instead.
+ /// This type used to declare the module name multi2vec-google-gemini, which no server
+ /// has ever provided, so a collection configured with it could not be created. The Gemini API
+ /// is reached through the multi2vec-google module by setting
+ /// to generativelanguage.googleapis.com,
+ /// which is what
+ /// now returns. Kept only so existing source that names the type still compiles.
///
- [Vectorizer("multi2vec-google-gemini")]
- public record Multi2VecGoogleGemini : VectorizerConfig
+ [Obsolete(
+ "Multi2VecGoogleGemini declared the non-existent module 'multi2vec-google-gemini' and "
+ + "could never create a collection. VectorizerFactory.Multi2VecGoogleGemini(...) now "
+ + "returns a Multi2VecGoogle with ApiEndpoint = 'generativelanguage.googleapis.com'; "
+ + "bind the result as Multi2VecGoogle (or VectorizerConfig) instead."
+ )]
+ public record Multi2VecGoogleGemini : Multi2VecGoogle
{
///
/// Initializes a new instance of the class
///
[JsonConstructor]
internal Multi2VecGoogleGemini() { }
-
- ///
- /// Gets or sets the value of the api endpoint
- ///
- public string? ApiEndpoint { get; set; } = "generativelanguage.googleapis.com";
-
- ///
- /// Gets or sets the value of the image fields
- ///
- public string[]? ImageFields { get; set; } = null;
-
- ///
- /// Gets or sets the value of the text fields
- ///
- public string[]? TextFields { get; set; } = null;
-
- ///
- /// Gets or sets the value of the video fields
- ///
- public string[]? VideoFields { get; set; } = null;
-
- ///
- /// Gets or sets the value of the audio fields
- ///
- public string[]? AudioFields { get; set; } = null;
-
- ///
- /// Gets or sets the value of the video interval seconds
- ///
- public int? VideoIntervalSeconds { get; set; } = null;
-
- ///
- /// Gets or sets the value of the model
- ///
- [JsonPropertyName("modelId")]
- public string? Model { get; set; } = null;
-
- ///
- /// Gets or sets the per-modality weights (the weights object), omitted when null.
- /// [JsonInclude] is required: the serializer skips internal properties by default.
- ///
- [JsonInclude]
- [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
- internal VectorizerWeights? Weights { get; set; } = null;
}
///
diff --git a/src/Weaviate.Client/PublicAPI.Unshipped.txt b/src/Weaviate.Client/PublicAPI.Unshipped.txt
index 30fd71ba..d8ae5b26 100644
--- a/src/Weaviate.Client/PublicAPI.Unshipped.txt
+++ b/src/Weaviate.Client/PublicAPI.Unshipped.txt
@@ -313,3 +313,33 @@ Weaviate.Client.Models.Vectorizer.Multi2VecTwelveLabs.TextFields.get -> string![
Weaviate.Client.Models.Vectorizer.Multi2VecTwelveLabs.TextFields.set -> void
Weaviate.Client.VectorizerFactory.Multi2VecTwelveLabs(string![]? imageFields = null, string![]? textFields = null, string? baseURL = null, string? model = null) -> Weaviate.Client.Models.VectorizerConfig!
Weaviate.Client.VectorizerFactory.Multi2VecTwelveLabs(Weaviate.Client.Models.WeightedFields! imageFields, Weaviate.Client.Models.WeightedFields! textFields, string? baseURL = null, string? model = null) -> Weaviate.Client.Models.VectorizerConfig!
+*REMOVED*Weaviate.Client.Models.Vectorizer.Multi2VecGoogle.Location.get -> string!
+*REMOVED*Weaviate.Client.Models.Vectorizer.Multi2VecGoogle.ProjectId.get -> string!
+*REMOVED*Weaviate.Client.Models.Vectorizer.Multi2VecGoogleGemini.ApiEndpoint.get -> string?
+*REMOVED*Weaviate.Client.Models.Vectorizer.Multi2VecGoogleGemini.ApiEndpoint.set -> void
+*REMOVED*Weaviate.Client.Models.Vectorizer.Multi2VecGoogleGemini.AudioFields.get -> string![]?
+*REMOVED*Weaviate.Client.Models.Vectorizer.Multi2VecGoogleGemini.AudioFields.set -> void
+*REMOVED*Weaviate.Client.Models.Vectorizer.Multi2VecGoogleGemini.ImageFields.get -> string![]?
+*REMOVED*Weaviate.Client.Models.Vectorizer.Multi2VecGoogleGemini.ImageFields.set -> void
+*REMOVED*Weaviate.Client.Models.Vectorizer.Multi2VecGoogleGemini.Model.get -> string?
+*REMOVED*Weaviate.Client.Models.Vectorizer.Multi2VecGoogleGemini.Model.set -> void
+*REMOVED*Weaviate.Client.Models.Vectorizer.Multi2VecGoogleGemini.TextFields.get -> string![]?
+*REMOVED*Weaviate.Client.Models.Vectorizer.Multi2VecGoogleGemini.TextFields.set -> void
+*REMOVED*Weaviate.Client.Models.Vectorizer.Multi2VecGoogleGemini.VideoFields.get -> string![]?
+*REMOVED*Weaviate.Client.Models.Vectorizer.Multi2VecGoogleGemini.VideoFields.set -> void
+*REMOVED*Weaviate.Client.Models.Vectorizer.Multi2VecGoogleGemini.VideoIntervalSeconds.get -> int?
+*REMOVED*Weaviate.Client.Models.Vectorizer.Multi2VecGoogleGemini.VideoIntervalSeconds.set -> void
+*REMOVED*override sealed Weaviate.Client.Models.Vectorizer.Multi2VecGoogleGemini.Equals(Weaviate.Client.Models.VectorizerConfig? other) -> bool
+*REMOVED*Weaviate.Client.VectorizerFactory.Multi2VecGoogle(string! projectId, string! location, string![]? imageFields = null, string![]? textFields = null, string![]? videoFields = null, string![]? audioFields = null, int? videoIntervalSeconds = null, string? model = null, int? dimensions = null, bool? vectorizeCollectionName = null) -> Weaviate.Client.Models.VectorizerConfig!
+*REMOVED*Weaviate.Client.VectorizerFactory.Multi2VecGoogle(string! projectId, string! location, Weaviate.Client.Models.WeightedFields! imageFields, Weaviate.Client.Models.WeightedFields! textFields, Weaviate.Client.Models.WeightedFields! videoFields, Weaviate.Client.Models.WeightedFields! audioFields, int? videoIntervalSeconds = null, string? model = null, int? dimensions = null, bool? vectorizeCollectionName = null) -> Weaviate.Client.Models.VectorizerConfig!
+*REMOVED*Weaviate.Client.VectorizerFactory.Multi2VecGoogleGemini(string![]? imageFields = null, string![]? textFields = null, string![]? videoFields = null, string![]? audioFields = null, string? apiEndpoint = null, int? videoIntervalSeconds = null, string? model = null) -> Weaviate.Client.Models.VectorizerConfig!
+*REMOVED*Weaviate.Client.VectorizerFactory.Multi2VecGoogleGemini(Weaviate.Client.Models.WeightedFields! imageFields, Weaviate.Client.Models.WeightedFields! textFields, Weaviate.Client.Models.WeightedFields! videoFields, Weaviate.Client.Models.WeightedFields! audioFields, string? apiEndpoint = null, int? videoIntervalSeconds = null, string? model = null) -> Weaviate.Client.Models.VectorizerConfig!
+override sealed Weaviate.Client.Models.Vectorizer.Multi2VecGoogleGemini.Equals(Weaviate.Client.Models.Vectorizer.Multi2VecGoogle? other) -> bool
+Weaviate.Client.Models.Vectorizer.Multi2VecGoogle.ApiEndpoint.get -> string?
+Weaviate.Client.Models.Vectorizer.Multi2VecGoogle.ApiEndpoint.set -> void
+Weaviate.Client.Models.Vectorizer.Multi2VecGoogle.Location.get -> string?
+Weaviate.Client.Models.Vectorizer.Multi2VecGoogle.ProjectId.get -> string?
+Weaviate.Client.VectorizerFactory.Multi2VecGoogle(string! projectId, string! location, string![]? imageFields = null, string![]? textFields = null, string![]? videoFields = null, string![]? audioFields = null, int? videoIntervalSeconds = null, string? model = null, int? dimensions = null, bool? vectorizeCollectionName = null, string? apiEndpoint = null) -> Weaviate.Client.Models.VectorizerConfig!
+Weaviate.Client.VectorizerFactory.Multi2VecGoogle(string! projectId, string! location, Weaviate.Client.Models.WeightedFields! imageFields, Weaviate.Client.Models.WeightedFields! textFields, Weaviate.Client.Models.WeightedFields! videoFields, Weaviate.Client.Models.WeightedFields! audioFields, int? videoIntervalSeconds = null, string? model = null, int? dimensions = null, bool? vectorizeCollectionName = null, string? apiEndpoint = null) -> Weaviate.Client.Models.VectorizerConfig!
+Weaviate.Client.VectorizerFactory.Multi2VecGoogleGemini(string![]? imageFields = null, string![]? textFields = null, string![]? videoFields = null, string![]? audioFields = null, string? apiEndpoint = null, int? videoIntervalSeconds = null, string? model = null, int? dimensions = null, bool? vectorizeCollectionName = null) -> Weaviate.Client.Models.VectorizerConfig!
+Weaviate.Client.VectorizerFactory.Multi2VecGoogleGemini(Weaviate.Client.Models.WeightedFields! imageFields, Weaviate.Client.Models.WeightedFields! textFields, Weaviate.Client.Models.WeightedFields! videoFields, Weaviate.Client.Models.WeightedFields! audioFields, string? apiEndpoint = null, int? videoIntervalSeconds = null, string? model = null, int? dimensions = null, bool? vectorizeCollectionName = null) -> Weaviate.Client.Models.VectorizerConfig!
From 0e83cd9e7b6005afeed57146b539c661b55a801b Mon Sep 17 00:00:00 2001
From: Ivan Despot <66276597+g-despot@users.noreply.github.com>
Date: Fri, 14 Aug 2026 11:11:42 +0200
Subject: [PATCH 02/14] feat: send incremental_base_backup_id on backup create
The generated DTO has carried Incremental_base_backup_id since the 1.37 spec
(Rest/Dto/Models.g.cs) and the server reads it
(entities/models/backup_create_request.go:51), but nothing in the client
could set it: BackupCreateRequest had no such member and
BuildBackupCreateRequest never populated one. Asking for an incremental
backup was impossible; python has had it since 4.20.2
(weaviate/backup/executor.py).
The version gate is on the field rather than on the operation. A
[RequiresWeaviateVersion] attribute on Create would refuse every backup on a
pre-1.37 server, which is a regression for plain backups, so this follows
CollectionsClient.EnsureTextAnalyzerFeaturesSupported instead: check only
when the caller actually supplied a base id, and throw the same
WeaviateVersionMismatchException the rest of the client's gates throw. Python
gates identically (executor.py:92-96).
The base id is passed through verbatim rather than lowercased. Python lowers
it, but this client does not lower request.Id either, and applying the rule
to one id and not the other would be the surprising behaviour.
Tests cover all four quadrants: the key reaches the wire when set, is absent
when unset, a pre-1.37 server rejects an incremental request with the right
RequiredVersion/ActualVersion, and a plain backup on that same old server
still succeeds.
---
.../Unit/TestBackupClient.cs | 152 ++++++++++++++++++
src/Weaviate.Client/BackupClient.cs | 39 +++++
src/Weaviate.Client/Models/Backup.cs | 14 +-
src/Weaviate.Client/PublicAPI.Unshipped.txt | 6 +
4 files changed, 210 insertions(+), 1 deletion(-)
diff --git a/src/Weaviate.Client.Tests/Unit/TestBackupClient.cs b/src/Weaviate.Client.Tests/Unit/TestBackupClient.cs
index f7daeb46..1b87d344 100644
--- a/src/Weaviate.Client.Tests/Unit/TestBackupClient.cs
+++ b/src/Weaviate.Client.Tests/Unit/TestBackupClient.cs
@@ -216,4 +216,156 @@ BackupStatus expected
Assert.Equal(expected, backup.Status);
}
+
+ ///
+ /// A create response body, reused by the incremental-backup cases below.
+ ///
+ private const string CreateResponseJson = """
+ {
+ "id": "my-backup",
+ "backend": "filesystem",
+ "status": "STARTED",
+ "path": "/backups"
+ }
+ """;
+
+ ///
+ /// Create() must put IncrementalBaseBackupId on the wire under the spec's
+ /// incremental_base_backup_id key. Without it the client silently drops the
+ /// caller's request and takes a full backup instead of an incremental one.
+ ///
+ [Fact]
+ public async Task Create_SendsIncrementalBaseBackupId()
+ {
+ var (client, handler) = MockWeaviateClient.CreateWithMockHandler(
+ syncHandler: req =>
+ req.Method == HttpMethod.Post
+ ? new HttpResponseMessage(HttpStatusCode.OK)
+ {
+ Content = new StringContent(
+ CreateResponseJson,
+ System.Text.Encoding.UTF8,
+ "application/json"
+ ),
+ }
+ : null!,
+ serverVersion: "1.37.0"
+ );
+
+ await client.Backup.Create(
+ new BackupCreateRequest(
+ "my-backup",
+ new FilesystemBackend("/backups"),
+ IncrementalBaseBackupId: "base-backup"
+ ),
+ TestContext.Current.CancellationToken
+ );
+
+ Assert.NotNull(handler.LastRequest);
+ var body = await handler.LastRequest!.Content!.ReadAsStringAsync(
+ TestContext.Current.CancellationToken
+ );
+ Assert.Contains("\"incremental_base_backup_id\":\"base-backup\"", body);
+ }
+
+ ///
+ /// A create request that asks for no base backup must not send the key at all, so a plain
+ /// backup is unchanged.
+ ///
+ [Fact]
+ public async Task Create_OmitsIncrementalBaseBackupId_WhenNotRequested()
+ {
+ var (client, handler) = MockWeaviateClient.CreateWithMockHandler(
+ syncHandler: req =>
+ req.Method == HttpMethod.Post
+ ? new HttpResponseMessage(HttpStatusCode.OK)
+ {
+ Content = new StringContent(
+ CreateResponseJson,
+ System.Text.Encoding.UTF8,
+ "application/json"
+ ),
+ }
+ : null!,
+ serverVersion: "1.37.0"
+ );
+
+ await client.Backup.Create(
+ new BackupCreateRequest("my-backup", new FilesystemBackend("/backups")),
+ TestContext.Current.CancellationToken
+ );
+
+ Assert.NotNull(handler.LastRequest);
+ var body = await handler.LastRequest!.Content!.ReadAsStringAsync(
+ TestContext.Current.CancellationToken
+ );
+ Assert.DoesNotContain("incremental_base_backup_id", body);
+ }
+
+ ///
+ /// Incremental backups arrived in Weaviate 1.37.0, so asking for one against an older
+ /// server must fail in the client rather than silently producing a full backup.
+ ///
+ [Fact]
+ public async Task Create_Throws_WhenIncrementalRequestedOnOlderServer()
+ {
+ var (client, _) = MockWeaviateClient.CreateWithMockHandler(
+ syncHandler: req =>
+ req.Method == HttpMethod.Post
+ ? new HttpResponseMessage(HttpStatusCode.OK)
+ {
+ Content = new StringContent(
+ CreateResponseJson,
+ System.Text.Encoding.UTF8,
+ "application/json"
+ ),
+ }
+ : null!,
+ serverVersion: "1.36.0"
+ );
+
+ var exception = await Assert.ThrowsAsync(async () =>
+ await client.Backup.Create(
+ new BackupCreateRequest(
+ "my-backup",
+ new FilesystemBackend("/backups"),
+ IncrementalBaseBackupId: "base-backup"
+ ),
+ TestContext.Current.CancellationToken
+ )
+ );
+
+ Assert.Equal(new Version(1, 37, 0), exception.RequiredVersion);
+ Assert.Equal(new Version(1, 36, 0), exception.ActualVersion);
+ }
+
+ ///
+ /// The version gate is on the field, not the operation: a plain backup must still work on a
+ /// server older than 1.37.0.
+ ///
+ [Fact]
+ public async Task Create_PlainBackup_Succeeds_OnOlderServer()
+ {
+ var (client, handler) = MockWeaviateClient.CreateWithMockHandler(
+ syncHandler: req =>
+ req.Method == HttpMethod.Post
+ ? new HttpResponseMessage(HttpStatusCode.OK)
+ {
+ Content = new StringContent(
+ CreateResponseJson,
+ System.Text.Encoding.UTF8,
+ "application/json"
+ ),
+ }
+ : null!,
+ serverVersion: "1.36.0"
+ );
+
+ await client.Backup.Create(
+ new BackupCreateRequest("my-backup", new FilesystemBackend("/backups")),
+ TestContext.Current.CancellationToken
+ );
+
+ Assert.NotNull(handler.LastRequest);
+ }
}
diff --git a/src/Weaviate.Client/BackupClient.cs b/src/Weaviate.Client/BackupClient.cs
index 30c36234..e5a42076 100644
--- a/src/Weaviate.Client/BackupClient.cs
+++ b/src/Weaviate.Client/BackupClient.cs
@@ -56,6 +56,8 @@ public async Task Create(
CancellationToken cancellationToken = default
)
{
+ await EnsureIncrementalBackupSupported(request);
+
var restRequest = BuildBackupCreateRequest(request);
var response = await _client.RestClient.BackupCreate(
request.Backend.Provider,
@@ -85,6 +87,42 @@ public async Task CreateSync(
return await operation.WaitForCompletion(timeout, cancellationToken);
}
+ ///
+ /// The first Weaviate server version that accepts a file-based incremental backup base.
+ ///
+ private static readonly Version IncrementalBackupMinimumVersion = new(1, 37, 0);
+
+ ///
+ /// Throws when the request asks for an
+ /// incremental backup and the connected server predates support for it. Gated on the field
+ /// rather than on the whole operation, because plain backups still work on older servers.
+ ///
+ /// The request
+ ///
+ /// Thrown when is set and the
+ /// connected server version is below 1.37.0.
+ ///
+ private async Task EnsureIncrementalBackupSupported(BackupCreateRequest request)
+ {
+ if (string.IsNullOrEmpty(request.IncrementalBaseBackupId))
+ return;
+
+ await _client.EnsureInitializedAsync();
+
+ var serverVersion = _client.WeaviateVersion;
+ if (serverVersion is null)
+ return;
+
+ if (serverVersion < IncrementalBackupMinimumVersion)
+ {
+ throw new WeaviateVersionMismatchException(
+ nameof(BackupCreateRequest.IncrementalBaseBackupId),
+ IncrementalBackupMinimumVersion,
+ serverVersion
+ );
+ }
+ }
+
///
/// Builds the backup create request using the specified request
///
@@ -101,6 +139,7 @@ private Rest.Dto.BackupCreateRequest BuildBackupCreateRequest(BackupCreateReques
Id = request.Id,
Include = request.IncludeCollections?.ToList(),
Exclude = request.ExcludeCollections?.ToList(),
+ Incremental_base_backup_id = request.IncrementalBaseBackupId,
Config = new Rest.Dto.BackupConfig
{
Bucket = bucket,
diff --git a/src/Weaviate.Client/Models/Backup.cs b/src/Weaviate.Client/Models/Backup.cs
index 95adcb39..1528bf82 100644
--- a/src/Weaviate.Client/Models/Backup.cs
+++ b/src/Weaviate.Client/Models/Backup.cs
@@ -300,13 +300,25 @@ public record Backup(
///
/// Options for creating a backup
///
+/// The identifier of the backup to create.
+/// The storage backend to write the backup to.
+/// The collections to include; all when null.
+/// The collections to exclude; none when null.
+/// The share of CPU the backup may use.
+/// The compression level to write with.
+///
+/// The identifier of an existing backup to use as the base for a file-based incremental backup.
+/// Files unchanged since the base backup are not copied again and are restored from the base.
+/// Requires Weaviate server version 1.37.0 or later.
+///
public record BackupCreateRequest(
string Id,
BackupBackend Backend,
AutoArray? IncludeCollections = null,
AutoArray? ExcludeCollections = null,
int? CPUPercentage = null,
- BackupCompressionLevel? CompressionLevel = null
+ BackupCompressionLevel? CompressionLevel = null,
+ string? IncrementalBaseBackupId = null
);
///
diff --git a/src/Weaviate.Client/PublicAPI.Unshipped.txt b/src/Weaviate.Client/PublicAPI.Unshipped.txt
index d8ae5b26..1c5bc8f5 100644
--- a/src/Weaviate.Client/PublicAPI.Unshipped.txt
+++ b/src/Weaviate.Client/PublicAPI.Unshipped.txt
@@ -343,3 +343,9 @@ Weaviate.Client.VectorizerFactory.Multi2VecGoogle(string! projectId, string! loc
Weaviate.Client.VectorizerFactory.Multi2VecGoogle(string! projectId, string! location, Weaviate.Client.Models.WeightedFields! imageFields, Weaviate.Client.Models.WeightedFields! textFields, Weaviate.Client.Models.WeightedFields! videoFields, Weaviate.Client.Models.WeightedFields! audioFields, int? videoIntervalSeconds = null, string? model = null, int? dimensions = null, bool? vectorizeCollectionName = null, string? apiEndpoint = null) -> Weaviate.Client.Models.VectorizerConfig!
Weaviate.Client.VectorizerFactory.Multi2VecGoogleGemini(string![]? imageFields = null, string![]? textFields = null, string![]? videoFields = null, string![]? audioFields = null, string? apiEndpoint = null, int? videoIntervalSeconds = null, string? model = null, int? dimensions = null, bool? vectorizeCollectionName = null) -> Weaviate.Client.Models.VectorizerConfig!
Weaviate.Client.VectorizerFactory.Multi2VecGoogleGemini(Weaviate.Client.Models.WeightedFields! imageFields, Weaviate.Client.Models.WeightedFields! textFields, Weaviate.Client.Models.WeightedFields! videoFields, Weaviate.Client.Models.WeightedFields! audioFields, string? apiEndpoint = null, int? videoIntervalSeconds = null, string? model = null, int? dimensions = null, bool? vectorizeCollectionName = null) -> Weaviate.Client.Models.VectorizerConfig!
+*REMOVED*Weaviate.Client.Models.BackupCreateRequest.BackupCreateRequest(string! Id, Weaviate.Client.Models.BackupBackend! Backend, Weaviate.Client.Internal.AutoArray? IncludeCollections = null, Weaviate.Client.Internal.AutoArray? ExcludeCollections = null, int? CPUPercentage = null, Weaviate.Client.Models.BackupCompressionLevel? CompressionLevel = null) -> void
+*REMOVED*Weaviate.Client.Models.BackupCreateRequest.Deconstruct(out string! Id, out Weaviate.Client.Models.BackupBackend! Backend, out Weaviate.Client.Internal.AutoArray? IncludeCollections, out Weaviate.Client.Internal.AutoArray? ExcludeCollections, out int? CPUPercentage, out Weaviate.Client.Models.BackupCompressionLevel? CompressionLevel) -> void
+Weaviate.Client.Models.BackupCreateRequest.BackupCreateRequest(string! Id, Weaviate.Client.Models.BackupBackend! Backend, Weaviate.Client.Internal.AutoArray? IncludeCollections = null, Weaviate.Client.Internal.AutoArray? ExcludeCollections = null, int? CPUPercentage = null, Weaviate.Client.Models.BackupCompressionLevel? CompressionLevel = null, string? IncrementalBaseBackupId = null) -> void
+Weaviate.Client.Models.BackupCreateRequest.Deconstruct(out string! Id, out Weaviate.Client.Models.BackupBackend! Backend, out Weaviate.Client.Internal.AutoArray? IncludeCollections, out Weaviate.Client.Internal.AutoArray? ExcludeCollections, out int? CPUPercentage, out Weaviate.Client.Models.BackupCompressionLevel? CompressionLevel, out string? IncrementalBaseBackupId) -> void
+Weaviate.Client.Models.BackupCreateRequest.IncrementalBaseBackupId.get -> string?
+Weaviate.Client.Models.BackupCreateRequest.IncrementalBaseBackupId.init -> void
From 6c647932f5fc0aca52ce5b4169754aca50e9df41 Mon Sep 17 00:00:00 2001
From: Ivan Despot <66276597+g-despot@users.noreply.github.com>
Date: Fri, 14 Aug 2026 11:14:34 +0200
Subject: [PATCH 03/14] fix: backup list dropped Size and the incremental base
id it had parsed
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
ToModelListItem read the list payload and then threw two fields away.
Size is the outright bug: the property exists on Backup, the create-status
path fills it, and Anonymous3 carries it — but the list mapper never
assigned it, so every listed backup reported Size == null whatever the
server said.
The incremental base id had nowhere to go, so Backup gains it. Python added
the same field to BackupListReturn in 4.23.0. Anonymous3 is an nswag
anonymous-schema name, so it was re-checked rather than assumed: Rest/Backup.cs
decodes the list response as List, and that record's shape
(id/classes/status/startedAt/completedAt/size/incremental_base_backup_id)
matches the list item in the spec.
The create-status response carries incremental_base_backup_id too, and the
Backup model is shared between both paths, so that mapper sets it as well —
otherwise the field would have been silently null on the status path, which
is the same defect this commit removes from the list path.
---
.../Unit/TestBackupClient.cs | 111 ++++++++++++++++++
src/Weaviate.Client/BackupClient.cs | 7 +-
src/Weaviate.Client/Models/Backup.cs | 6 +
src/Weaviate.Client/PublicAPI.Unshipped.txt | 2 +
4 files changed, 125 insertions(+), 1 deletion(-)
diff --git a/src/Weaviate.Client.Tests/Unit/TestBackupClient.cs b/src/Weaviate.Client.Tests/Unit/TestBackupClient.cs
index 1b87d344..8099822f 100644
--- a/src/Weaviate.Client.Tests/Unit/TestBackupClient.cs
+++ b/src/Weaviate.Client.Tests/Unit/TestBackupClient.cs
@@ -368,4 +368,115 @@ await client.Backup.Create(
Assert.NotNull(handler.LastRequest);
}
+
+ ///
+ /// List() parses the list payload and then discarded two fields it had already read: Size
+ /// was left null for every listed backup even though the property exists and the create
+ /// status path fills it, and the incremental base id had nowhere to go at all. Both must
+ /// survive onto the model.
+ ///
+ [Fact]
+ public async Task List_PopulatesSizeAndIncrementalBaseBackupId()
+ {
+ var json = """
+ [
+ {
+ "id": "my-backup",
+ "classes": ["Article"],
+ "status": "SUCCESS",
+ "startedAt": "2026-08-14T10:00:00Z",
+ "completedAt": "2026-08-14T10:05:00Z",
+ "size": 2.5,
+ "incremental_base_backup_id": "base-backup"
+ }
+ ]
+ """;
+
+ var (client, _) = MockWeaviateClient.CreateWithMockHandler(
+ syncHandler: _ => new HttpResponseMessage(HttpStatusCode.OK)
+ {
+ Content = new StringContent(json, System.Text.Encoding.UTF8, "application/json"),
+ }
+ );
+
+ var backups = await client.Backup.List(
+ BackupStorageProvider.Filesystem,
+ TestContext.Current.CancellationToken
+ );
+
+ var backup = Assert.Single(backups);
+ Assert.Equal("my-backup", backup.Id);
+ Assert.Equal(2.5, backup.Size);
+ Assert.Equal("base-backup", backup.IncrementalBaseBackupId);
+ }
+
+ ///
+ /// A full (non-incremental) backup has no base, and the server omits the key entirely, so
+ /// the model must report null rather than an empty string.
+ ///
+ [Fact]
+ public async Task List_IncrementalBaseBackupIdIsNull_ForFullBackup()
+ {
+ var json = """
+ [
+ {
+ "id": "my-backup",
+ "classes": ["Article"],
+ "status": "SUCCESS",
+ "size": 2.5
+ }
+ ]
+ """;
+
+ var (client, _) = MockWeaviateClient.CreateWithMockHandler(
+ syncHandler: _ => new HttpResponseMessage(HttpStatusCode.OK)
+ {
+ Content = new StringContent(json, System.Text.Encoding.UTF8, "application/json"),
+ }
+ );
+
+ var backups = await client.Backup.List(
+ BackupStorageProvider.Filesystem,
+ TestContext.Current.CancellationToken
+ );
+
+ var backup = Assert.Single(backups);
+ Assert.Null(backup.IncrementalBaseBackupId);
+ Assert.Equal(2.5, backup.Size);
+ }
+
+ ///
+ /// The create-status response carries the same field, and the model is shared with the list
+ /// path, so leaving it unset there would reintroduce the same silent null.
+ ///
+ [Fact]
+ public async Task GetStatus_PopulatesIncrementalBaseBackupId()
+ {
+ var json = """
+ {
+ "id": "my-backup",
+ "status": "SUCCESS",
+ "path": "/backups",
+ "backend": "filesystem",
+ "size": 1.5,
+ "incremental_base_backup_id": "base-backup"
+ }
+ """;
+
+ var (client, _) = MockWeaviateClient.CreateWithMockHandler(
+ syncHandler: _ => new HttpResponseMessage(HttpStatusCode.OK)
+ {
+ Content = new StringContent(json, System.Text.Encoding.UTF8, "application/json"),
+ }
+ );
+
+ var backup = await client.Backup.GetStatus(
+ new FilesystemBackend("/backups"),
+ "my-backup",
+ TestContext.Current.CancellationToken
+ );
+
+ Assert.Equal("base-backup", backup.IncrementalBaseBackupId);
+ Assert.Equal(1.5, backup.Size);
+ }
}
diff --git a/src/Weaviate.Client/BackupClient.cs b/src/Weaviate.Client/BackupClient.cs
index e5a42076..99cce289 100644
--- a/src/Weaviate.Client/BackupClient.cs
+++ b/src/Weaviate.Client/BackupClient.cs
@@ -435,6 +435,7 @@ private static Backup ToModel(Rest.Dto.BackupCreateStatusResponse dto, BackupBac
)
{
Size = dto.Size,
+ IncrementalBaseBackupId = dto.Incremental_base_backup_id,
};
///
@@ -487,5 +488,9 @@ private static Backup ToModelListItem(Rest.Dto.Anonymous3 dto) =>
dto.StartedAt,
dto.CompletedAt,
null
- );
+ )
+ {
+ Size = dto.Size,
+ IncrementalBaseBackupId = dto.Incremental_base_backup_id,
+ };
}
diff --git a/src/Weaviate.Client/Models/Backup.cs b/src/Weaviate.Client/Models/Backup.cs
index 1528bf82..f531168f 100644
--- a/src/Weaviate.Client/Models/Backup.cs
+++ b/src/Weaviate.Client/Models/Backup.cs
@@ -295,6 +295,12 @@ public record Backup(
/// Gets the size of the backup in GiB. Available after completion.
///
public double? Size { get; init; }
+
+ ///
+ /// Gets the id of the base backup this backup was built on, or null when the backup is not
+ /// incremental.
+ ///
+ public string? IncrementalBaseBackupId { get; init; }
}
///
diff --git a/src/Weaviate.Client/PublicAPI.Unshipped.txt b/src/Weaviate.Client/PublicAPI.Unshipped.txt
index 1c5bc8f5..ffc35323 100644
--- a/src/Weaviate.Client/PublicAPI.Unshipped.txt
+++ b/src/Weaviate.Client/PublicAPI.Unshipped.txt
@@ -349,3 +349,5 @@ Weaviate.Client.Models.BackupCreateRequest.BackupCreateRequest(string! Id, Weavi
Weaviate.Client.Models.BackupCreateRequest.Deconstruct(out string! Id, out Weaviate.Client.Models.BackupBackend! Backend, out Weaviate.Client.Internal.AutoArray? IncludeCollections, out Weaviate.Client.Internal.AutoArray? ExcludeCollections, out int? CPUPercentage, out Weaviate.Client.Models.BackupCompressionLevel? CompressionLevel, out string? IncrementalBaseBackupId) -> void
Weaviate.Client.Models.BackupCreateRequest.IncrementalBaseBackupId.get -> string?
Weaviate.Client.Models.BackupCreateRequest.IncrementalBaseBackupId.init -> void
+Weaviate.Client.Models.Backup.IncrementalBaseBackupId.get -> string?
+Weaviate.Client.Models.Backup.IncrementalBaseBackupId.init -> void
From 4ef693c3c774b36b9edb60a567cbada5b7c31770 Mon Sep 17 00:00:00 2001
From: Ivan Despot <66276597+g-despot@users.noreply.github.com>
Date: Fri, 14 Aug 2026 11:29:32 +0200
Subject: [PATCH 04/14] feat: add the generative-deepseek module (Weaviate
1.39)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The client had zero references to generative-deepseek. Adds the collection
config (GenerativeConfig.Deepseek + GenerativeConfigFactory.Deepseek +
the serialization arm) and the runtime provider (Providers.Deepseek +
GenerativeProviderFactory.Deepseek + the Search.Builders mapping), following
the Databricks precedent throughout.
Wire keys are taken from the server, not from the C# property names:
modules/generative-deepseek/config/class_settings.go spells the base url
"baseURL", and the camelCase policy happens to produce exactly that, so no
[JsonPropertyName] is needed. That is load-bearing rather than lucky — a
baseUrl/baseURL slip is silent, since the server ignores an unknown key and
quietly uses its own endpoint — so the unit test pins the literal string, and
was confirmed to fail when the casing is forced the other way.
Includes a vendored proto sync. src/Weaviate.Client/gRPC/proto/v1/
generative.proto was one feature behind upstream: GenerativeDeepseek was
missing from the GenerativeProvider oneof (field 16) along with the message
itself and GenerativeDeepseekMetadata, so the runtime provider could not be
expressed at all. The four hunks are copied verbatim from Weaviate v1.39.0's
grpc/proto/v1/generative.proto; the vendored file now differs from upstream
only by the deliberate `option csharp_namespace` line, and no other drift was
found in it. The metadata message is unused today — the client does not read
GenerativeMetadata anywhere — but is included so the file stays a faithful
copy and the next sync is a clean diff.
Note for the integration test: on a class using named vectors, Weaviate
1.39.0 rejects any fractional float in this module's config, e.g.
temperature 0.7 comes back as "Wrong temperature configuration, values are
between 0.0 and 2.0". The client sends ordinary JSON numbers and the same
body is accepted when the class uses a class-level vectorizer, so this is a
server defect: the value arrives as a json.Number, getNumberValue's
non-integer path returns the caller's defaultValue, and this module passes a
-100 sentinel there. generative-openai fails the same way; generative-cohere
does not. The integration test therefore uses integral floats so it exercises
all eight keys against a real server, and the unit test carries the
fractional values.
---
ci/docker-compose.yml | 2 +-
.../Integration/TestCollections.cs | 58 +++++++++++++
.../Unit/TestCollection.cs | 74 +++++++++++++++++
.../Unit/TestGenerativeShortcuts.cs | 83 +++++++++++++++++++
.../Configure/GenerativeConfig.cs | 34 ++++++++
.../Configure/GenerativeProvider.cs | 34 ++++++++
.../Models/Generative/Providers.cs | 46 ++++++++++
.../Models/GenerativeConfig.cs | 62 ++++++++++++++
.../Models/Serialization.GenerativeConfig.cs | 5 ++
src/Weaviate.Client/PublicAPI.Unshipped.txt | 60 ++++++++++++++
src/Weaviate.Client/gRPC/Search.Builders.cs | 13 +++
.../gRPC/proto/v1/generative.proto | 22 +++++
12 files changed, 492 insertions(+), 1 deletion(-)
diff --git a/ci/docker-compose.yml b/ci/docker-compose.yml
index 20485dfb..72d71416 100644
--- a/ci/docker-compose.yml
+++ b/ci/docker-compose.yml
@@ -22,7 +22,7 @@ services:
QUERY_DEFAULTS_LIMIT: 25
AUTHENTICATION_ANONYMOUS_ACCESS_ENABLED: 'true'
PERSISTENCE_DATA_PATH: '/var/lib/weaviate'
- ENABLE_MODULES: text2vec-transformers,text2vec-cohere,backup-filesystem,generative-dummy,generative-anyscale,reranker-dummy,reranker-cohere,text2vec-ollama,generative-ollama,multi2vec-google
+ ENABLE_MODULES: text2vec-transformers,text2vec-cohere,backup-filesystem,generative-dummy,generative-anyscale,reranker-dummy,reranker-cohere,text2vec-ollama,generative-ollama,multi2vec-google,generative-deepseek
BACKUP_FILESYSTEM_PATH: "/tmp/backups"
EXPORT_ENABLED: 'true'
EXPORT_DEFAULT_PATH: "/tmp/exports"
diff --git a/src/Weaviate.Client.Tests/Integration/TestCollections.cs b/src/Weaviate.Client.Tests/Integration/TestCollections.cs
index b81b6953..62075890 100644
--- a/src/Weaviate.Client.Tests/Integration/TestCollections.cs
+++ b/src/Weaviate.Client.Tests/Integration/TestCollections.cs
@@ -193,6 +193,64 @@ public async Task Collection_Creates_And_Retrieves_Generative_Config()
Assert.IsType(collection.GenerativeConfig);
}
+ ///
+ /// Tests that a generative-deepseek collection round-trips through a real server. The module
+ /// validates its own settings on create (temperature, both penalties and topP all have
+ /// ranges, and baseURL is parsed), so a misspelt key or a wrong type shows up here rather
+ /// than silently falling back to a default.
+ ///
+ ///
+ /// Every float here is deliberately integral. On a class that uses named vectors, Weaviate
+ /// 1.39.0 rejects any fractional float in this module's config — temperature: 0.7
+ /// comes back as "Wrong temperature configuration, values are between 0.0 and 2.0". The
+ /// client sends plain JSON numbers and the same payload is accepted on a class-level
+ /// vectorizer, so this is a server-side defect, not a client one: the settings helper
+ /// receives the value as a json.Number, its non-integer path returns the caller's
+ /// "defaultValue", and generative-deepseek passes a -100 sentinel there
+ /// (usecases/modulecomponents/settings/class_settings_property_helper.go getNumberValue,
+ /// modules/generative-deepseek/config/class_settings.go getFloatProperty).
+ /// generative-openai fails identically. Fractional values and the exact wire keys are
+ /// covered by the unit test instead.
+ ///
+ [Fact]
+ public async Task Collection_Creates_And_Retrieves_GenerativeDeepseek_Config()
+ {
+ RequireModule("generative-deepseek");
+
+ // Arrange
+ var collectionClient = await CollectionFactory(
+ properties: [Property.Text("Name")],
+ generativeConfig: Configure.Generative.Deepseek(
+ model: "deepseek-chat",
+ temperature: 1,
+ maxTokens: 2048,
+ frequencyPenalty: 0,
+ presencePenalty: 0,
+ topP: 1,
+ baseURL: "https://api.deepseek.com",
+ stop: ["\n\n"]
+ )
+ );
+
+ // Act
+ var collection = await _weaviate
+ .Collections.Use(collectionClient.Name)
+ .Config.Get(TestContext.Current.CancellationToken);
+
+ // Assert
+ Assert.NotNull(collection);
+ var deepseek = Assert.IsType(collection.GenerativeConfig);
+ Assert.Equal("deepseek-chat", deepseek.Model);
+ Assert.Equal(1, deepseek.Temperature);
+ Assert.Equal(2048, deepseek.MaxTokens);
+ Assert.Equal(0, deepseek.FrequencyPenalty);
+ Assert.Equal(0, deepseek.PresencePenalty);
+ Assert.Equal(1, deepseek.TopP);
+ Assert.Equal("https://api.deepseek.com", deepseek.BaseURL);
+ Assert.NotNull(deepseek.Stop);
+ Assert.Equal(["\n\n"], deepseek.Stop);
+ }
+
///
/// Tests that test collections export
///
diff --git a/src/Weaviate.Client.Tests/Unit/TestCollection.cs b/src/Weaviate.Client.Tests/Unit/TestCollection.cs
index 7f5ca918..9cc8290b 100644
--- a/src/Weaviate.Client.Tests/Unit/TestCollection.cs
+++ b/src/Weaviate.Client.Tests/Unit/TestCollection.cs
@@ -388,6 +388,80 @@ public void Collection_GenerativeGoogleVertex_Serializes_And_RoundTrips_Location
Assert.Equal("us-east4", typed.Location);
}
+ ///
+ /// Tests that the DeepSeek generative config serializes every setting under the exact key
+ /// the module reads, omits what is unset, and round-trips. The keys are pinned literally
+ /// against modules/generative-deepseek/config/class_settings.go, in particular
+ /// baseURL: the camelCase policy happens to produce the right casing here, but a
+ /// baseUrl/baseURL slip is silent — the server just ignores the unknown key
+ /// and falls back to its default endpoint.
+ ///
+ [Fact]
+ public void Collection_GenerativeDeepseek_Serializes_And_RoundTrips()
+ {
+ // Arrange
+ var full = Assert.IsType(
+ Configure.Generative.Deepseek(
+ model: "deepseek-chat",
+ temperature: 0.7,
+ maxTokens: 2048,
+ frequencyPenalty: 0.5,
+ presencePenalty: 0.25,
+ topP: 0.9,
+ baseURL: "https://api.deepseek.com",
+ stop: ["\n\n"]
+ )
+ );
+ var empty = Assert.IsType(Configure.Generative.Deepseek());
+
+ // Act
+ var jsonFull = JsonSerializer.Serialize(
+ full,
+ Rest.WeaviateRestClient.RestJsonSerializerOptions
+ );
+ var jsonEmpty = JsonSerializer.Serialize(
+ empty,
+ Rest.WeaviateRestClient.RestJsonSerializerOptions
+ );
+
+ // Assert
+ Assert.Equal("generative-deepseek", full.Type);
+ Assert.Contains("\"model\":\"deepseek-chat\"", jsonFull);
+ Assert.Contains("\"temperature\":0.7", jsonFull);
+ Assert.Contains("\"maxTokens\":2048", jsonFull);
+ Assert.Contains("\"frequencyPenalty\":0.5", jsonFull);
+ Assert.Contains("\"presencePenalty\":0.25", jsonFull);
+ Assert.Contains("\"topP\":0.9", jsonFull);
+ Assert.Contains("\"baseURL\":\"https://api.deepseek.com\"", jsonFull);
+ Assert.Contains("\"stop\":[\"\\n\\n\"]", jsonFull);
+ // Every generative config also writes its own "type": IGenerativeConfig.Type carries
+ // [JsonIgnore] but attributes on an interface member do not apply to the implementing
+ // property. Pre-existing for all providers and accepted by the server; asserted here so
+ // DeepSeek is shown to behave the same rather than differently.
+ Assert.Contains("\"type\":\"generative-deepseek\"", jsonFull);
+
+ // Nothing set means nothing else sent, so the server keeps its own defaults.
+ Assert.Equal("{\"type\":\"generative-deepseek\"}", jsonEmpty);
+
+ var roundTripped = GenerativeConfigSerialization.Factory(
+ GenerativeConfig.Deepseek.TypeValue,
+ JsonSerializer.Deserialize