), options);
+ }
+}
diff --git a/Google.GenAI/Interactions/Core/HttpRequest.cs b/Google.GenAI/Interactions/Core/HttpRequest.cs
new file mode 100644
index 00000000..50e29496
--- /dev/null
+++ b/Google.GenAI/Interactions/Core/HttpRequest.cs
@@ -0,0 +1,40 @@
+// 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.
+
+using System.Net.Http;
+
+namespace Google.GenAI.Interactions.Core;
+
+public sealed class HttpRequest
+ where P : ParamsBase
+{
+ public HttpMethod Method { get; init; } = null!;
+
+ public P Params { get; init; } = null!;
+
+ public override string ToString() =>
+ string.Format("Method: {0}\n{1}", this.Method.ToString(), this.Params.ToString());
+
+ public override bool Equals(object? obj)
+ {
+ if (obj is not HttpRequest
other)
+ {
+ return false;
+ }
+
+ return this.Method.Equals(other.Method) && this.Params.Equals(other.Params);
+ }
+
+ public override int GetHashCode() => 0;
+}
diff --git a/Google.GenAI/Interactions/Core/HttpResponse.cs b/Google.GenAI/Interactions/Core/HttpResponse.cs
new file mode 100644
index 00000000..4bcf0650
--- /dev/null
+++ b/Google.GenAI/Interactions/Core/HttpResponse.cs
@@ -0,0 +1,192 @@
+// 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.
+
+using System;
+using System.Collections.Generic;
+using System.Diagnostics.CodeAnalysis;
+using System.IO;
+using System.Net;
+using System.Net.Http;
+using System.Runtime.CompilerServices;
+using System.Text.Json;
+using System.Threading.Tasks;
+using Google.GenAI.Interactions.Exceptions;
+using Threading = System.Threading;
+
+namespace Google.GenAI.Interactions.Core;
+
+public class HttpResponse : IDisposable
+{
+ public HttpResponseMessage RawMessage { get; init; } = null!;
+
+ public IEnumerable>> Headers
+ {
+ get { return RawMessage.Headers; }
+ }
+
+ public bool IsSuccessStatusCode
+ {
+ get { return RawMessage.IsSuccessStatusCode; }
+ }
+
+ public HttpStatusCode StatusCode
+ {
+ get { return RawMessage.StatusCode; }
+ }
+
+ public Threading::CancellationToken CancellationToken { get; init; } = default;
+
+ public IEnumerable GetHeaderValues(string name) => RawMessage.Headers.GetValues(name);
+
+ public bool TryGetHeaderValues(
+ string name,
+ [NotNullWhen(true)] out IEnumerable? values
+ ) => RawMessage.Headers.TryGetValues(name, out values);
+
+ public sealed override string ToString() => this.RawMessage.ToString();
+
+ public override bool Equals(object? obj)
+ {
+ if (obj is not HttpResponse other)
+ {
+ return false;
+ }
+
+ return this.RawMessage.Equals(other.RawMessage);
+ }
+
+ public override int GetHashCode() => this.RawMessage.GetHashCode();
+
+ public async Task Deserialize(Threading::CancellationToken cancellationToken = default)
+ {
+ using var cts = Threading::CancellationTokenSource.CreateLinkedTokenSource(
+ this.CancellationToken,
+ cancellationToken
+ );
+ try
+ {
+ return await JsonSerializer
+ .DeserializeAsync(
+ await this.ReadAsStream(cts.Token).ConfigureAwait(false),
+ ModelBase.SerializerOptions,
+ cts.Token
+ )
+ .ConfigureAwait(false)
+ ?? throw new GeminiNextGenApiInvalidDataException("Response cannot be null");
+ }
+ catch (HttpRequestException e)
+ {
+ throw new GeminiNextGenApiIOException("I/O Exception", e);
+ }
+ }
+
+ public async Task ReadAsStream(Threading::CancellationToken cancellationToken = default)
+ {
+ using var cts = Threading::CancellationTokenSource.CreateLinkedTokenSource(
+ this.CancellationToken,
+ cancellationToken
+ );
+ return await RawMessage.Content.ReadAsStreamAsync(
+#if NET
+ cts.Token
+#endif
+ ).ConfigureAwait(false);
+ }
+
+ public async Task ReadAsString(Threading::CancellationToken cancellationToken = default)
+ {
+ using var cts = Threading::CancellationTokenSource.CreateLinkedTokenSource(
+ this.CancellationToken,
+ cancellationToken
+ );
+ return await RawMessage.Content.ReadAsStringAsync(
+#if NET
+ cts.Token
+#endif
+ ).ConfigureAwait(false);
+ }
+
+ public void Dispose()
+ {
+ this.RawMessage.Dispose();
+ GC.SuppressFinalize(this);
+ }
+}
+
+public sealed class HttpResponse : HttpResponse
+{
+ readonly Func> _deserialize;
+
+ internal HttpResponse(Func> deserialize)
+ {
+ this._deserialize = deserialize;
+ }
+
+ [SetsRequiredMembers]
+ internal HttpResponse(
+ HttpResponse response,
+ Func> deserialize
+ )
+ : this(deserialize)
+ {
+ this.RawMessage = response.RawMessage;
+ this.CancellationToken = response.CancellationToken;
+ }
+
+ public Task Deserialize(Threading::CancellationToken cancellationToken = default)
+ {
+ using var cts = Threading::CancellationTokenSource.CreateLinkedTokenSource(
+ this.CancellationToken,
+ cancellationToken
+ );
+ return this._deserialize(cts.Token);
+ }
+}
+
+public sealed class StreamingHttpResponse : HttpResponse
+{
+ readonly Func> _enumerate;
+
+ internal StreamingHttpResponse(
+ Func> enumerate
+ )
+ {
+ this._enumerate = enumerate;
+ }
+
+ [SetsRequiredMembers]
+ internal StreamingHttpResponse(
+ HttpResponse response,
+ Func> enumerate
+ )
+ : this(enumerate)
+ {
+ this.RawMessage = response.RawMessage;
+ this.CancellationToken = response.CancellationToken;
+ }
+
+ public async IAsyncEnumerable Enumerate(
+ [EnumeratorCancellationAttribute] Threading::CancellationToken cancellationToken = default
+ )
+ {
+ using var cts = Threading::CancellationTokenSource.CreateLinkedTokenSource(
+ this.CancellationToken,
+ cancellationToken
+ );
+ await foreach (var item in this._enumerate(cts.Token))
+ {
+ yield return item;
+ }
+ }
+}
diff --git a/Google.GenAI/Interactions/Core/IPage.cs b/Google.GenAI/Interactions/Core/IPage.cs
new file mode 100644
index 00000000..c788c19f
--- /dev/null
+++ b/Google.GenAI/Interactions/Core/IPage.cs
@@ -0,0 +1,67 @@
+// 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.
+
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+using Google.GenAI.Interactions.Exceptions;
+
+namespace Google.GenAI.Interactions.Core;
+
+///
+/// An interface representing a single page, with items of type , from a
+/// paginated endpoint response.
+///
+public interface IPage
+{
+ ///
+ /// The items in this page.
+ ///
+ IReadOnlyList Items { get; }
+
+ ///
+ /// Returns whether there's another page after this one.
+ ///
+ /// The method doesn't make requests so the result depends entirely on the
+ /// data in this page. If a significant amount of time has passed between requesting
+ /// this page and calling this method, then the result could be stale.
+ ///
+ bool HasNext();
+
+ ///
+ /// Returns the page after this one by making another request.
+ ///
+ ///
+ /// Thrown when it's impossible to get the next page. This exception is avoidable by calling
+ /// first.
+ ///
+ ///
+ Task> Next(CancellationToken cancellationToken = default);
+
+ ///
+ /// Validates that the page was constructed with a valid response (based on its own
+ /// Validate method).
+ ///
+ ///
+ /// Thrown when the instance does not pass validation.
+ ///
+ ///
+ void Validate();
+
+#if NET
+ ///
+ public IAsyncEnumerable Paginate(CancellationToken cancellationToken = default) =>
+ IPageExtensions.Paginate(this, cancellationToken);
+#endif
+}
diff --git a/Google.GenAI/Interactions/Core/JsonDictionary.cs b/Google.GenAI/Interactions/Core/JsonDictionary.cs
new file mode 100644
index 00000000..e121b301
--- /dev/null
+++ b/Google.GenAI/Interactions/Core/JsonDictionary.cs
@@ -0,0 +1,199 @@
+// 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.
+
+using System;
+using System.Collections.Concurrent;
+using System.Collections.Frozen;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text.Json;
+using Google.GenAI.Interactions.Exceptions;
+
+namespace Google.GenAI.Interactions.Core;
+
+///
+/// A dictionary that holds JSON data.
+///
+/// It can be mutated and then frozen once no more mutations are expected.
+/// This is useful for allowing the dictionary to be modified by a class's
+/// init properties, but then preventing it from being modified afterwards.
+///
+/// It also caches data deserialization for performance.
+///
+sealed class JsonDictionary
+{
+ IReadOnlyDictionary _rawData;
+
+ readonly ConcurrentDictionary _deserializedData;
+
+ Dictionary MutableRawData
+ {
+ get
+ {
+ if (_rawData is Dictionary dictionary)
+ {
+ return dictionary;
+ }
+ throw new InvalidOperationException("Can't mutate after freezing.");
+ }
+ }
+
+ public JsonDictionary()
+ {
+ _rawData = new Dictionary();
+ _deserializedData = new();
+ }
+
+ public JsonDictionary(IReadOnlyDictionary dictionary)
+ {
+ _rawData = Enumerable.ToDictionary(dictionary, (e) => e.Key, (e) => e.Value);
+ _deserializedData = new();
+ }
+
+ public JsonDictionary(FrozenDictionary dictionary)
+ {
+ _rawData = dictionary;
+ _deserializedData = new();
+ }
+
+ public JsonDictionary(JsonDictionary dictionary)
+ {
+ _rawData = Enumerable.ToDictionary(dictionary._rawData, (e) => e.Key, (e) => e.Value);
+ _deserializedData = new(dictionary._deserializedData);
+ }
+
+ ///
+ /// Freezes this dictionary and returns a readonly view of it.
+ ///
+ /// Future calls to mutating methods on this class will throw
+ /// .
+ ///
+ public IReadOnlyDictionary Freeze()
+ {
+ if (_rawData is FrozenDictionary dictionary)
+ {
+ return dictionary;
+ }
+
+ var frozenRawData = FrozenDictionary.ToFrozenDictionary(_rawData);
+ _rawData = frozenRawData;
+ return frozenRawData;
+ }
+
+ public void Set(string key, T value)
+ {
+ MutableRawData[key] = JsonSerializer.SerializeToElement(value, ModelBase.SerializerOptions);
+ _deserializedData[key] = value;
+ }
+
+ public T GetNotNullClass(string key)
+ where T : class
+ {
+ if (_deserializedData.TryGetValue(key, out var cached) && cached is T t)
+ {
+ return t;
+ }
+ if (!_rawData.TryGetValue(key, out JsonElement element))
+ {
+ throw new GeminiNextGenApiInvalidDataException($"'{key}' cannot be absent");
+ }
+ T deserialized = WrappedJsonSerializer.GetNotNullClass(element, key);
+ _deserializedData[key] = deserialized;
+ return deserialized;
+ }
+
+ public T GetNotNullStruct(string key)
+ where T : struct
+ {
+ if (_deserializedData.TryGetValue(key, out var cached) && cached is T t)
+ {
+ return t;
+ }
+ if (!_rawData.TryGetValue(key, out JsonElement element))
+ {
+ throw new GeminiNextGenApiInvalidDataException($"'{key}' cannot be absent");
+ }
+ T deserialized = WrappedJsonSerializer.GetNotNullStruct(element, key);
+ _deserializedData[key] = deserialized;
+ return deserialized;
+ }
+
+ public T? GetNullableClass(string key)
+ where T : class
+ {
+ if (_deserializedData.TryGetValue(key, out var cached) && (cached == null || cached is T))
+ {
+ return (T?)cached;
+ }
+ if (!_rawData.TryGetValue(key, out JsonElement element))
+ {
+ _deserializedData[key] = null;
+ return null;
+ }
+ T? deserialized = WrappedJsonSerializer.GetNullableClass(element, key);
+ _deserializedData[key] = deserialized;
+ return deserialized;
+ }
+
+ public T? GetNullableStruct(string key)
+ where T : struct
+ {
+ if (_deserializedData.TryGetValue(key, out var cached) && (cached == null || cached is T))
+ {
+ return (T?)cached;
+ }
+ if (!_rawData.TryGetValue(key, out JsonElement element))
+ {
+ _deserializedData[key] = null;
+ return null;
+ }
+ T? deserialized = WrappedJsonSerializer.GetNullableStruct(element, key);
+ _deserializedData[key] = deserialized;
+ return deserialized;
+ }
+
+ public override string ToString() =>
+ JsonSerializer.Serialize(
+ FriendlyJsonPrinter.PrintValue(this._rawData),
+ ModelBase.ToStringSerializerOptions
+ );
+
+ public override bool Equals(object? obj)
+ {
+ if (obj is not JsonDictionary other || _rawData.Count != other._rawData.Count)
+ {
+ return false;
+ }
+
+ foreach (var item in _rawData)
+ {
+ if (!other._rawData.TryGetValue(item.Key, out var otherValue))
+ {
+ return false;
+ }
+
+ if (!JsonElement.DeepEquals(item.Value, otherValue))
+ {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ public override int GetHashCode()
+ {
+ return 0;
+ }
+}
diff --git a/Google.GenAI/Interactions/Core/JsonModel.cs b/Google.GenAI/Interactions/Core/JsonModel.cs
new file mode 100644
index 00000000..aa3d4b24
--- /dev/null
+++ b/Google.GenAI/Interactions/Core/JsonModel.cs
@@ -0,0 +1,79 @@
+// 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.
+
+using System.Collections.Generic;
+using System.Text.Json;
+
+namespace Google.GenAI.Interactions.Core;
+
+///
+/// The base class for all API objects that are serialized as JSON objects.
+///
+/// API objects such as enums and unions do not inherit from this class.
+///
+public abstract record class JsonModel : ModelBase
+{
+ private protected JsonDictionary _rawData = new();
+
+ ///
+ /// The backing JSON properties of the instance.
+ ///
+ public IReadOnlyDictionary RawData
+ {
+ get { return this._rawData.Freeze(); }
+ }
+
+ protected JsonModel(JsonModel jsonModel)
+ : base(jsonModel)
+ {
+ this._rawData = new(jsonModel._rawData);
+ }
+
+ public sealed override string ToString() => this._rawData.ToString();
+
+ public virtual bool Equals(JsonModel? other)
+ {
+ if (other == null)
+ {
+ return false;
+ }
+
+ return this._rawData.Equals(other._rawData);
+ }
+
+ public override int GetHashCode() => this._rawData.GetHashCode();
+}
+
+///
+/// NOTE: Do not inherit from this type outside the SDK unless you're okay with breaking
+/// changes in non-major versions. We may add new methods in the future that cause
+/// existing derived classes to break.
+///
+/// NOTE: This interface is in the style of a factory instance instead of using
+/// abstract static methods because .NET Standard 2.0 doesn't support abstract static methods.
+///
+interface IFromRawJson
+{
+ ///
+ /// Returns an instance constructed from the given raw JSON properties.
+ ///
+ /// Required field and type mismatches are not checked. In these cases accessing
+ /// the relevant properties of the constructed instance may throw.
+ ///
+ /// This method is useful for constructing an instance from already serialized
+ /// data or for sending arbitrary data to the API (e.g. for undocumented or not
+ /// yet supported properties or values).
+ ///
+ T FromRawUnchecked(IReadOnlyDictionary rawData);
+}
diff --git a/Google.GenAI/Interactions/Core/JsonModelConverter.cs b/Google.GenAI/Interactions/Core/JsonModelConverter.cs
new file mode 100644
index 00000000..19ec9709
--- /dev/null
+++ b/Google.GenAI/Interactions/Core/JsonModelConverter.cs
@@ -0,0 +1,46 @@
+// 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.
+
+using System;
+using System.Collections.Generic;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace Google.GenAI.Interactions.Core;
+
+sealed class JsonModelConverter : JsonConverter
+ where TModel : JsonModel
+ where TFromRaw : IFromRawJson, new()
+{
+ public override TModel? Read(
+ ref Utf8JsonReader reader,
+ Type typeToConvert,
+ JsonSerializerOptions options
+ )
+ {
+ var rawData = JsonSerializer.Deserialize>(
+ ref reader,
+ options
+ );
+ if (rawData == null)
+ return null;
+
+ return new TFromRaw().FromRawUnchecked(rawData);
+ }
+
+ public override void Write(Utf8JsonWriter writer, TModel value, JsonSerializerOptions options)
+ {
+ JsonSerializer.Serialize(writer, value.RawData, options);
+ }
+}
diff --git a/Google.GenAI/Interactions/Core/ModelBase.cs b/Google.GenAI/Interactions/Core/ModelBase.cs
new file mode 100644
index 00000000..752be1f4
--- /dev/null
+++ b/Google.GenAI/Interactions/Core/ModelBase.cs
@@ -0,0 +1,101 @@
+// 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.
+
+using System.Text.Json;
+using Google.GenAI.Interactions.Exceptions;
+using Google.GenAI.Interactions.Models.Interactions;
+using Interactions = Google.GenAI.Interactions.Models.Interactions;
+
+namespace Google.GenAI.Interactions.Core;
+
+///
+/// The base class for all API objects with properties.
+///
+/// API objects such as enums do not inherit from this class.
+///
+public abstract record class ModelBase
+{
+ protected ModelBase(ModelBase modelBase)
+ {
+ // Nothing to copy. Just so that subclasses can define copy constructors.
+ }
+
+ internal static readonly JsonSerializerOptions SerializerOptions = new()
+ {
+ Converters =
+ {
+ new FrozenDictionaryConverterFactory(),
+ new ApiEnumConverter(),
+ new ApiEnumConverter(),
+ new ApiEnumConverter(),
+ new ApiEnumConverter(),
+ new ApiEnumConverter(),
+ new ApiEnumConverter(),
+ new ApiEnumConverter(),
+ new ApiEnumConverter(),
+ new ApiEnumConverter(),
+ new ApiEnumConverter(),
+ new ApiEnumConverter(),
+ new ApiEnumConverter(),
+ new ApiEnumConverter(),
+ new ApiEnumConverter(),
+ new ApiEnumConverter(),
+ new ApiEnumConverter(),
+ new ApiEnumConverter(),
+ new ApiEnumConverter(),
+ new ApiEnumConverter(),
+ new ApiEnumConverter(),
+ new ApiEnumConverter(),
+ new ApiEnumConverter(),
+ new ApiEnumConverter(),
+ new ApiEnumConverter(),
+ new ApiEnumConverter(),
+ new ApiEnumConverter(),
+ new ApiEnumConverter(),
+ new ApiEnumConverter(),
+ new ApiEnumConverter(),
+ new ApiEnumConverter(),
+ new ApiEnumConverter(),
+ new ApiEnumConverter(),
+ new ApiEnumConverter(),
+ new ApiEnumConverter(),
+ new ApiEnumConverter(),
+ new ApiEnumConverter(),
+ new ApiEnumConverter(),
+ new ApiEnumConverter(),
+ new ApiEnumConverter(),
+ new ApiEnumConverter(),
+ new ApiEnumConverter(),
+ new ApiEnumConverter(),
+ },
+ };
+
+ internal static readonly JsonSerializerOptions ToStringSerializerOptions = new(
+ SerializerOptions
+ )
+ {
+ WriteIndented = true,
+ };
+
+ ///
+ /// Validates that all required fields are set and that each field's value is of the expected type.
+ ///
+ /// This is useful for instances constructed from raw JSON data (e.g. deserialized from an API response).
+ ///
+ ///
+ /// Thrown when the instance does not pass validation.
+ ///
+ ///
+ public abstract void Validate();
+}
diff --git a/Google.GenAI/Interactions/Core/MultipartJsonDictionary.cs b/Google.GenAI/Interactions/Core/MultipartJsonDictionary.cs
new file mode 100644
index 00000000..41be0bc4
--- /dev/null
+++ b/Google.GenAI/Interactions/Core/MultipartJsonDictionary.cs
@@ -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.
+
+using System;
+using System.Collections.Concurrent;
+using System.Collections.Frozen;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text.Json;
+using Google.GenAI.Interactions.Exceptions;
+
+namespace Google.GenAI.Interactions.Core;
+
+///
+/// A dictionary that holds mixed JSON and binary content.
+///
+/// It can be mutated and then frozen once no more mutations are expected.
+/// This is useful for allowing the dictionary to be modified by a class's
+/// init properties, but then preventing it from being modified afterwards.
+///
+/// It also caches data deserialization for performance.
+///
+sealed class MultipartJsonDictionary
+{
+ IReadOnlyDictionary _rawData;
+
+ readonly ConcurrentDictionary _deserializedData;
+
+ Dictionary MutableRawData
+ {
+ get
+ {
+ if (_rawData is Dictionary dictionary)
+ {
+ return dictionary;
+ }
+ throw new InvalidOperationException("Can't mutate after freezing.");
+ }
+ }
+
+ public MultipartJsonDictionary()
+ {
+ _rawData = new Dictionary();
+ _deserializedData = new();
+ }
+
+ public MultipartJsonDictionary(IReadOnlyDictionary dictionary)
+ {
+ _rawData = Enumerable.ToDictionary(dictionary, (e) => e.Key, (e) => e.Value);
+ _deserializedData = new();
+ }
+
+ public MultipartJsonDictionary(FrozenDictionary dictionary)
+ {
+ _rawData = dictionary;
+ _deserializedData = new();
+ }
+
+ public MultipartJsonDictionary(MultipartJsonDictionary dictionary)
+ {
+ _rawData = Enumerable.ToDictionary(dictionary._rawData, (e) => e.Key, (e) => e.Value);
+ _deserializedData = new(dictionary._deserializedData);
+ }
+
+ ///
+ /// Freezes this dictionary and returns a readonly view of it.
+ ///
+ /// Future calls to mutating methods on this class will throw
+ /// .
+ ///
+ public IReadOnlyDictionary Freeze()
+ {
+ if (_rawData is FrozenDictionary dictionary)
+ {
+ return dictionary;
+ }
+
+ var frozenRawData = FrozenDictionary.ToFrozenDictionary(_rawData);
+ _rawData = frozenRawData;
+ return frozenRawData;
+ }
+
+ public void Set(string key, T value)
+ {
+ MutableRawData[key] = MultipartJsonSerializer.SerializeToElement(
+ value,
+ ModelBase.SerializerOptions
+ );
+ _deserializedData[key] = value;
+ }
+
+ public T GetNotNullClass(string key)
+ where T : class
+ {
+ if (_deserializedData.TryGetValue(key, out var cached) && cached is T t)
+ {
+ return t;
+ }
+ if (!_rawData.TryGetValue(key, out MultipartJsonElement element))
+ {
+ throw new GeminiNextGenApiInvalidDataException($"'{key}' cannot be absent");
+ }
+ T? deserialized = WrappedMultipartJsonSerializer.GetNotNullClass(element, key);
+ _deserializedData[key] = deserialized;
+ return deserialized;
+ }
+
+ public T GetNotNullStruct(string key)
+ where T : struct
+ {
+ if (_deserializedData.TryGetValue(key, out var cached) && cached is T t)
+ {
+ return t;
+ }
+ if (!_rawData.TryGetValue(key, out MultipartJsonElement element))
+ {
+ throw new GeminiNextGenApiInvalidDataException($"'{key}' cannot be absent");
+ }
+ T deserialized = WrappedMultipartJsonSerializer.GetNotNullStruct(element, key);
+ _deserializedData[key] = deserialized;
+ return deserialized;
+ }
+
+ public T? GetNullableClass(string key)
+ where T : class
+ {
+ if (_deserializedData.TryGetValue(key, out var cached) && (cached == null || cached is T))
+ {
+ return (T?)cached;
+ }
+ if (!_rawData.TryGetValue(key, out MultipartJsonElement element))
+ {
+ _deserializedData[key] = null;
+ return null;
+ }
+ T? deserialized = WrappedMultipartJsonSerializer.GetNullableClass(element, key);
+ _deserializedData[key] = deserialized;
+ return deserialized;
+ }
+
+ public T? GetNullableStruct(string key)
+ where T : struct
+ {
+ if (_deserializedData.TryGetValue(key, out var cached) && (cached == null || cached is T))
+ {
+ return (T?)cached;
+ }
+ if (!_rawData.TryGetValue(key, out MultipartJsonElement element))
+ {
+ _deserializedData[key] = null;
+ return null;
+ }
+ T? deserialized = WrappedMultipartJsonSerializer.GetNullableStruct(element, key);
+ _deserializedData[key] = deserialized;
+ return deserialized;
+ }
+
+ public override string ToString() =>
+ JsonSerializer.Serialize(
+ FriendlyJsonPrinter.PrintValue(this._rawData),
+ ModelBase.ToStringSerializerOptions
+ );
+
+ public override bool Equals(object? obj)
+ {
+ if (obj is not MultipartJsonDictionary other || _rawData.Count != other._rawData.Count)
+ {
+ return false;
+ }
+
+ foreach (var item in _rawData)
+ {
+ if (!other._rawData.TryGetValue(item.Key, out var otherValue))
+ {
+ return false;
+ }
+
+ if (!MultipartJsonElement.DeepEquals(item.Value, otherValue))
+ {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ public override int GetHashCode()
+ {
+ return 0;
+ }
+}
diff --git a/Google.GenAI/Interactions/Core/MultipartJsonElement.cs b/Google.GenAI/Interactions/Core/MultipartJsonElement.cs
new file mode 100644
index 00000000..f33d9eaf
--- /dev/null
+++ b/Google.GenAI/Interactions/Core/MultipartJsonElement.cs
@@ -0,0 +1,461 @@
+// 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.
+
+using System;
+using System.Collections.Frozen;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Net.Http;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+using System.Threading;
+
+namespace Google.GenAI.Interactions.Core;
+
+///
+/// A that can contain .
+///
+/// Use to construct or read instances of this class.
+///
+public readonly struct MultipartJsonElement
+{
+ ///
+ /// A with placeholders
+ /// for .
+ ///
+ internal JsonElement Json { get; init; } = default;
+
+ ///
+ /// A dictionary from placeholder string in the JSON to
+ /// .
+ ///
+ internal IReadOnlyDictionary BinaryContents { get; init; } =
+ FrozenDictionary.ToFrozenDictionary(new Dictionary());
+
+ public static implicit operator MultipartJsonElement(JsonElement json) => new() { Json = json };
+
+ public MultipartJsonElement() { }
+
+ public override string ToString() =>
+ JsonSerializer.Serialize(
+ FriendlyJsonPrinter.PrintValue(this),
+ ModelBase.ToStringSerializerOptions
+ );
+
+ public static bool DeepEquals(MultipartJsonElement a, MultipartJsonElement b) =>
+ MultipartJsonElement.DeepEqualsInner(a.Json, a.BinaryContents, b.Json, b.BinaryContents);
+
+ static bool DeepEqualsInner(
+ JsonElement jsonA,
+ IReadOnlyDictionary binaryA,
+ JsonElement jsonB,
+ IReadOnlyDictionary binaryB
+ )
+ {
+ if (jsonA.ValueKind != jsonB.ValueKind)
+ {
+ return false;
+ }
+
+ switch (jsonA.ValueKind)
+ {
+ case JsonValueKind.Undefined:
+ case JsonValueKind.Null:
+ case JsonValueKind.True:
+ case JsonValueKind.False:
+ return true;
+ case JsonValueKind.Number:
+ return JsonElement.DeepEquals(jsonA, jsonB);
+ case JsonValueKind.String:
+ BinaryContent? aContent = null;
+
+ BinaryContent? bContent = null;
+
+ if (jsonA.TryGetGuid(out var guidA) && binaryA.TryGetValue(guidA, out var a))
+ {
+ aContent = a;
+ }
+
+ if (jsonB.TryGetGuid(out var guidB) && binaryB.TryGetValue(guidB, out var b))
+ {
+ bContent = b;
+ }
+
+ if (aContent != null && bContent != null)
+ {
+ return aContent == bContent;
+ }
+ else if (aContent == null && bContent == null)
+ {
+ return jsonA.GetString() == jsonB.GetString();
+ }
+ else
+ {
+ return false;
+ }
+ case JsonValueKind.Object:
+ Dictionary propertiesA = new();
+
+ foreach (var item1 in jsonA.EnumerateObject())
+ {
+ propertiesA[item1.Name] = item1.Value;
+ }
+
+ Dictionary propertiesB = new();
+
+ foreach (var item1 in jsonB.EnumerateObject())
+ {
+ propertiesB[item1.Name] = item1.Value;
+ }
+
+ if (propertiesA.Count != propertiesB.Count)
+ {
+ return false;
+ }
+
+ foreach (var property in propertiesA)
+ {
+ if (!propertiesB.TryGetValue(property.Key, out var b1))
+ {
+ return false;
+ }
+
+ if (!MultipartJsonElement.DeepEqualsInner(property.Value, binaryA, b1, binaryB))
+ {
+ return false;
+ }
+ }
+
+ return true;
+ case JsonValueKind.Array:
+ if (jsonA.GetArrayLength() != jsonB.GetArrayLength())
+ {
+ return false;
+ }
+
+ var i = 0;
+ foreach (var item in jsonA.EnumerateArray())
+ {
+ if (!MultipartJsonElement.DeepEqualsInner(item, binaryA, jsonB[i], binaryB))
+ {
+ return false;
+ }
+
+ i++;
+ }
+
+ return true;
+ default:
+ throw new InvalidOperationException("Unreachable");
+ }
+ }
+}
+
+///
+/// A serializer for mixed JSON and binary content.
+///
+public static class MultipartJsonSerializer
+{
+ ///
+ /// The current dictionary from placeholder string to to use for
+ /// serialization/deserialization.
+ ///
+ /// This isn't a local variable because we want to share
+ /// for performance. It's also a thread local to avoid data races between threads.
+ ///
+ static readonly ThreadLocal?> CurrentBinaryContents = new(() =>
+ null
+ );
+
+ internal static Dictionary BinaryContents
+ {
+ get
+ {
+ return CurrentBinaryContents.Value
+ ?? throw new InvalidOperationException(
+ "Cannot convert BinaryContent without MultipartJsonSerializer"
+ );
+ }
+ }
+
+ static readonly ThreadLocal<
+ Dictionary
+ > MultipartSerializerOptionsCache = new(() => new());
+
+ static readonly JsonSerializerOptions DefaultMultipartSerializerOptions =
+ MultipartSerializerOptions(new());
+
+ static JsonSerializerOptions MultipartSerializerOptions(JsonSerializerOptions? options = null)
+ {
+ if (options == null)
+ {
+ return DefaultMultipartSerializerOptions;
+ }
+
+ if (!MultipartSerializerOptionsCache.Value!.TryGetValue(options, out var multipartOptions))
+ {
+ multipartOptions = new(options);
+ multipartOptions.Converters.Add(new BinaryContentConverter());
+ multipartOptions.Converters.Add(new MultipartJsonElementConverter());
+ MultipartSerializerOptionsCache.Value![options] = multipartOptions;
+ }
+
+ return multipartOptions;
+ }
+
+ public static MultipartJsonElement SerializeToElement(
+ T value,
+ JsonSerializerOptions? options = null
+ )
+ {
+ var previousBinaryContents = CurrentBinaryContents.Value;
+ try
+ {
+ CurrentBinaryContents.Value = new();
+ var element = JsonSerializer.SerializeToElement(
+ value,
+ MultipartSerializerOptions(options)
+ );
+ return new()
+ {
+ Json = element,
+ BinaryContents = FrozenDictionary.ToFrozenDictionary(CurrentBinaryContents.Value!),
+ };
+ }
+ finally
+ {
+ CurrentBinaryContents.Value = previousBinaryContents;
+ }
+ }
+
+ public static T? Deserialize(
+ MultipartJsonElement element,
+ JsonSerializerOptions? options = null
+ )
+ {
+ var previousBinaryContents = CurrentBinaryContents.Value;
+ try
+ {
+ CurrentBinaryContents.Value = Enumerable.ToDictionary(
+ element.BinaryContents,
+ (e) => e.Key,
+ (e) => e.Value
+ );
+ return JsonSerializer.Deserialize(element.Json, MultipartSerializerOptions(options));
+ }
+ finally
+ {
+ CurrentBinaryContents.Value = previousBinaryContents;
+ }
+ }
+
+ public static MultipartFormDataContent Serialize(
+ T value,
+ JsonSerializerOptions? options = null
+ )
+ {
+ MultipartFormDataContent formDataContent = new();
+ var multipartElement = MultipartJsonSerializer.SerializeToElement(value, options);
+ void SerializeParts(string name, JsonElement element)
+ {
+ HttpContent? content;
+ string? fileName = null;
+ switch (element.ValueKind)
+ {
+ case JsonValueKind.Undefined:
+ case JsonValueKind.Null:
+ return;
+ case JsonValueKind.Number:
+ content = new StringContent(element.ToString());
+ break;
+ case JsonValueKind.String:
+ if (
+ element.TryGetGuid(out var guid)
+ && multipartElement.BinaryContents.TryGetValue(guid, out var binaryContent)
+ )
+ {
+ content = new StreamContent(binaryContent.Stream);
+ content.Headers.ContentType = binaryContent.ContentType;
+ fileName = binaryContent.FileName;
+ }
+ else
+ {
+ content = new StringContent(element.ToString());
+ }
+ break;
+ case JsonValueKind.True:
+ content = new StringContent("true");
+ break;
+ case JsonValueKind.False:
+ content = new StringContent("false");
+ break;
+ case JsonValueKind.Object:
+ foreach (var item in element.EnumerateObject())
+ {
+ SerializeParts(
+ name == "" ? item.Name : string.Format("{0}[{1}]", name, item.Name),
+ item.Value
+ );
+ }
+ return;
+ case JsonValueKind.Array:
+ var items = new List();
+ foreach (var arrayItem in element.EnumerateArray())
+ {
+ switch (arrayItem.ValueKind)
+ {
+ case JsonValueKind.Undefined:
+ case JsonValueKind.Null:
+ items.Add("");
+ break;
+ case JsonValueKind.True:
+ items.Add("true");
+ break;
+ case JsonValueKind.False:
+ items.Add("false");
+ break;
+ case JsonValueKind.String:
+ if (
+ arrayItem.TryGetGuid(out var itemGuid)
+ && multipartElement.BinaryContents.TryGetValue(
+ itemGuid,
+ out var itemBinaryContent
+ )
+ )
+ {
+ var itemContent = new StreamContent(itemBinaryContent.Stream);
+ itemContent.Headers.ContentType = itemBinaryContent.ContentType;
+ var itemFileName = itemBinaryContent.FileName;
+ if (name == "")
+ {
+ formDataContent.Add(itemContent);
+ }
+ else if (itemFileName == null)
+ {
+ formDataContent.Add(itemContent, $"{name}[]");
+ }
+ else
+ {
+ formDataContent.Add(itemContent, $"{name}[]", itemFileName);
+ }
+ }
+ else
+ {
+ items.Add(arrayItem.ToString());
+ }
+ break;
+ default:
+ throw new InvalidDataException("Unexpected element type in array");
+ }
+ }
+
+ if (items.Count > 0)
+ {
+ content = new StringContent(string.Join(",", items));
+ }
+ else
+ {
+ content = null;
+ }
+
+ break;
+ default:
+ throw new ArgumentOutOfRangeException(nameof(element));
+ }
+
+ if (content != null)
+ {
+ if (name == "")
+ {
+ formDataContent.Add(content);
+ }
+ else if (fileName == null)
+ {
+ formDataContent.Add(content, name);
+ }
+ else
+ {
+ formDataContent.Add(content, name, fileName);
+ }
+ }
+ }
+ SerializeParts("", multipartElement.Json);
+ return formDataContent;
+ }
+}
+
+///
+/// A JSON converter that serializes/deserializes mixed JSON and binary content.
+///
+/// It uses placeholder IDs (see ), which are written/read
+/// to/from a thread-local dictionary, so it's expected that this converter is only invoked via
+/// , which ensures the dictionary is set up correctly.
+///
+sealed class MultipartJsonElementConverter : JsonConverter
+{
+ public override MultipartJsonElement Read(
+ ref Utf8JsonReader reader,
+ Type typeToConvert,
+ JsonSerializerOptions options
+ ) =>
+ new()
+ {
+ Json = JsonSerializer.Deserialize(ref reader, options),
+ BinaryContents = MultipartJsonSerializer.BinaryContents,
+ };
+
+ public override void Write(
+ Utf8JsonWriter writer,
+ MultipartJsonElement value,
+ JsonSerializerOptions options
+ )
+ {
+ foreach (var item in value.BinaryContents)
+ {
+ MultipartJsonSerializer.BinaryContents.Add(item.Key, item.Value);
+ }
+ JsonSerializer.Serialize(writer, value.Json, options);
+ }
+}
+
+///
+/// A JSON converter that serializes/deserializes binary content to/from placeholder IDs.
+///
+/// The placeholder IDs are written/read to/from a thread-local dictionary, so it's expected that
+/// this converter is only invoked via , which ensures the
+/// dictionary is set up correctly.
+///
+sealed class BinaryContentConverter : JsonConverter
+{
+ public override BinaryContent Read(
+ ref Utf8JsonReader reader,
+ Type typeToConvert,
+ JsonSerializerOptions options
+ ) =>
+ MultipartJsonSerializer.BinaryContents[
+ JsonSerializer.Deserialize(ref reader, options)
+ ];
+
+ public override void Write(
+ Utf8JsonWriter writer,
+ BinaryContent value,
+ JsonSerializerOptions options
+ )
+ {
+ var guid = Guid.NewGuid();
+ MultipartJsonSerializer.BinaryContents[guid] = value;
+ JsonSerializer.Serialize(writer, guid, options);
+ }
+}
diff --git a/Google.GenAI/Interactions/Core/MultipartJsonModel.cs b/Google.GenAI/Interactions/Core/MultipartJsonModel.cs
new file mode 100644
index 00000000..fbd5c378
--- /dev/null
+++ b/Google.GenAI/Interactions/Core/MultipartJsonModel.cs
@@ -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.
+
+using System.Collections.Generic;
+
+namespace Google.GenAI.Interactions.Core;
+
+///
+/// The base class for all API objects that are serialized as a mix of JSON objects
+/// and binary content.
+///
+/// API objects such as enums and unions do not inherit from this class.
+///
+public abstract record class MultipartJsonModel : ModelBase
+{
+ private protected MultipartJsonDictionary _rawData = new();
+
+ protected MultipartJsonModel(MultipartJsonModel jsonModel)
+ : base(jsonModel)
+ {
+ this._rawData = new(jsonModel._rawData);
+ }
+
+ ///
+ /// The backing mix of JSON and binary content properties of the instance.
+ ///
+ public IReadOnlyDictionary RawData
+ {
+ get { return this._rawData.Freeze(); }
+ }
+
+ public override int GetHashCode()
+ {
+ return 0;
+ }
+}
+
+///
+/// NOTE: Do not inherit from this type outside the SDK unless you're okay with breaking
+/// changes in non-major versions. We may add new methods in the future that cause
+/// existing derived classes to break.
+///
+/// NOTE: This interface is in the style of a factory instance instead of using
+/// abstract static methods because .NET Standard 2.0 doesn't support abstract static methods.
+///
+interface IFromRawMultipartJson
+{
+ ///
+ /// Returns an instance constructed from the given raw JSON properties.
+ ///
+ /// Required field and type mismatches are not checked. In these cases accessing
+ /// the relevant properties of the constructed instance may throw.
+ ///
+ /// This method is useful for constructing an instance from already serialized
+ /// data or for sending arbitrary data to the API (e.g. for undocumented or not
+ /// yet supported properties or values).
+ ///
+ T FromRawUnchecked(IReadOnlyDictionary rawData);
+}
diff --git a/Google.GenAI/Interactions/Core/MultipartJsonModelConverter.cs b/Google.GenAI/Interactions/Core/MultipartJsonModelConverter.cs
new file mode 100644
index 00000000..4e9d6410
--- /dev/null
+++ b/Google.GenAI/Interactions/Core/MultipartJsonModelConverter.cs
@@ -0,0 +1,46 @@
+// 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.
+
+using System;
+using System.Collections.Generic;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace Google.GenAI.Interactions.Core;
+
+sealed class MultipartJsonModelConverter : JsonConverter
+ where TModel : MultipartJsonModel
+ where TFromRaw : IFromRawMultipartJson, new()
+{
+ public override TModel? Read(
+ ref Utf8JsonReader reader,
+ Type typeToConvert,
+ JsonSerializerOptions options
+ )
+ {
+ var rawData = JsonSerializer.Deserialize>(
+ ref reader,
+ options
+ );
+ if (rawData == null)
+ return null;
+
+ return new TFromRaw().FromRawUnchecked(rawData);
+ }
+
+ public override void Write(Utf8JsonWriter writer, TModel value, JsonSerializerOptions options)
+ {
+ JsonSerializer.Serialize(writer, value.RawData, options);
+ }
+}
diff --git a/Google.GenAI/Interactions/Core/ParamsBase.cs b/Google.GenAI/Interactions/Core/ParamsBase.cs
new file mode 100644
index 00000000..39759d69
--- /dev/null
+++ b/Google.GenAI/Interactions/Core/ParamsBase.cs
@@ -0,0 +1,217 @@
+// 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.
+
+using System;
+using System.Collections.Generic;
+using System.Collections.Specialized;
+using System.Linq;
+using System.Net.Http;
+using System.Reflection;
+using System.Text;
+using System.Text.Json;
+using System.Web;
+
+namespace Google.GenAI.Interactions.Core;
+
+public abstract record class ParamsBase
+{
+ static readonly IReadOnlyDictionary defaultHeaders;
+
+ static ParamsBase()
+ {
+ defaultHeaders = new Dictionary { ["User-Agent"] = GetUserAgent() };
+ }
+
+ private protected JsonDictionary _rawQueryData = new();
+
+ private protected JsonDictionary _rawHeaderData = new();
+
+ protected ParamsBase(ParamsBase paramsBase)
+ {
+ this._rawHeaderData = new(paramsBase._rawHeaderData);
+ this._rawQueryData = new(paramsBase._rawQueryData);
+ }
+
+ public IReadOnlyDictionary RawQueryData
+ {
+ get { return this._rawQueryData.Freeze(); }
+ }
+
+ public IReadOnlyDictionary RawHeaderData
+ {
+ get { return this._rawHeaderData.Freeze(); }
+ }
+
+ public abstract Uri Url(ClientOptions options);
+
+ protected static void AddQueryElementToCollection(
+ NameValueCollection collection,
+ string key,
+ JsonElement element
+ )
+ {
+ switch (element.ValueKind)
+ {
+ case JsonValueKind.Undefined:
+ case JsonValueKind.Null:
+ collection.Add(key, "");
+ break;
+ case JsonValueKind.String:
+ case JsonValueKind.Number:
+ collection.Add(key, element.ToString());
+ break;
+ case JsonValueKind.True:
+ collection.Add(key, "true");
+ break;
+ case JsonValueKind.False:
+ collection.Add(key, "false");
+ break;
+ case JsonValueKind.Object:
+ foreach (var item in element.EnumerateObject())
+ {
+ AddQueryElementToCollection(
+ collection,
+ string.Format("{0}[{1}]", key, item.Name),
+ item.Value
+ );
+ }
+ break;
+ case JsonValueKind.Array:
+ collection.Add(
+ key,
+ string.Join(
+ ",",
+ Enumerable.Select(
+ element.EnumerateArray(),
+ x =>
+ x.ValueKind switch
+ {
+ JsonValueKind.Null => "",
+ JsonValueKind.True => "true",
+ JsonValueKind.False => "false",
+ _ => x.GetString(),
+ }
+ )
+ )
+ );
+ break;
+ }
+ }
+
+ protected static void AddHeaderElementToRequest(
+ HttpRequestMessage request,
+ string key,
+ JsonElement element
+ )
+ {
+ switch (element.ValueKind)
+ {
+ case JsonValueKind.Undefined:
+ case JsonValueKind.Null:
+ request.Headers.Add(key, "");
+ break;
+ case JsonValueKind.String:
+ case JsonValueKind.Number:
+ request.Headers.Add(key, element.ToString());
+ break;
+ case JsonValueKind.True:
+ request.Headers.Add(key, "true");
+ break;
+ case JsonValueKind.False:
+ request.Headers.Add(key, "false");
+ break;
+ case JsonValueKind.Object:
+ foreach (var item in element.EnumerateObject())
+ {
+ AddHeaderElementToRequest(
+ request,
+ string.Format("{0}.{1}", key, item.Name),
+ item.Value
+ );
+ }
+ break;
+ case JsonValueKind.Array:
+ foreach (var item in element.EnumerateArray())
+ {
+ request.Headers.Add(
+ key,
+ item.ValueKind switch
+ {
+ JsonValueKind.Null => "",
+ JsonValueKind.True => "true",
+ JsonValueKind.False => "false",
+ _ => item.GetString(),
+ }
+ );
+ }
+ break;
+ }
+ }
+
+ internal string QueryString(ClientOptions options)
+ {
+ NameValueCollection collection = new();
+ foreach (var item in this.RawQueryData)
+ {
+ ParamsBase.AddQueryElementToCollection(collection, item.Key, item.Value);
+ }
+ StringBuilder sb = new();
+ bool first = true;
+ foreach (var key in collection.AllKeys)
+ {
+ foreach (var value in collection.GetValues(key) ?? Enumerable.Empty())
+ {
+ if (!first)
+ {
+ sb.Append('&');
+ }
+ first = false;
+ sb.Append(HttpUtility.UrlEncode(key));
+ sb.Append('=');
+ sb.Append(HttpUtility.UrlEncode(value));
+ }
+ }
+ return sb.ToString();
+ }
+
+ internal abstract void AddHeadersToRequest(HttpRequestMessage request, ClientOptions options);
+
+ internal virtual HttpContent? BodyContent()
+ {
+ return null;
+ }
+
+ internal static void AddDefaultHeaders(HttpRequestMessage request, ClientOptions options)
+ {
+ foreach (var header in defaultHeaders)
+ {
+ request.Headers.Add(header.Key, header.Value);
+ }
+
+ if (options.ApiKey != null)
+ {
+ request.Headers.Add("x-goog-api-key", options.ApiKey);
+ }
+ }
+
+ static string GetUserAgent() =>
+ $"{typeof(GeminiNextGenApiClient).Name}/C# {GetPackageVersion()}";
+
+ static string GetPackageVersion() =>
+ Assembly
+ .GetExecutingAssembly()
+ .GetCustomAttribute()
+ ?.InformationalVersion
+ ?? "unknown";
+}
diff --git a/Google.GenAI/Interactions/Core/Sse.cs b/Google.GenAI/Interactions/Core/Sse.cs
new file mode 100644
index 00000000..427a4127
--- /dev/null
+++ b/Google.GenAI/Interactions/Core/Sse.cs
@@ -0,0 +1,72 @@
+// 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.
+
+using System.Collections.Generic;
+using System.Net.Http;
+using System.Net.ServerSentEvents;
+using System.Runtime.CompilerServices;
+using System.Text.Json;
+using System.Threading;
+using Google.GenAI.Interactions.Exceptions;
+
+namespace Google.GenAI.Interactions.Core;
+
+static class Sse
+{
+ internal static async IAsyncEnumerable Enumerate(
+ HttpResponseMessage response,
+ [EnumeratorCancellation] CancellationToken cancellationToken = default
+ )
+ {
+ using var stream = await response
+ .Content.ReadAsStreamAsync(
+#if NET
+ cancellationToken
+#endif
+ )
+ .ConfigureAwait(false);
+
+ var done = false;
+ await foreach (var item in SseParser.Create(stream).EnumerateAsync(cancellationToken))
+ {
+ // Stop emitting messages, but iterate through the full stream.
+ if (done)
+ {
+ continue;
+ }
+
+ if (item.Data.StartsWith("[DONE]"))
+ {
+ // In this case we don't break because we still want to iterate through the full stream.
+ done = true;
+ continue;
+ }
+
+ T? message;
+ try
+ {
+ message = JsonSerializer.Deserialize(item.Data, ModelBase.SerializerOptions);
+ }
+ catch (JsonException e)
+ {
+ throw new GeminiNextGenApiInvalidDataException(
+ $"Message must be of type {typeof(T).FullName}",
+ e
+ );
+ }
+ yield return message
+ ?? throw new GeminiNextGenApiInvalidDataException("Message cannot be null");
+ }
+ }
+}
diff --git a/Google.GenAI/Interactions/Core/WrappedJsonSerializer.cs b/Google.GenAI/Interactions/Core/WrappedJsonSerializer.cs
new file mode 100644
index 00000000..ae1a4fa1
--- /dev/null
+++ b/Google.GenAI/Interactions/Core/WrappedJsonSerializer.cs
@@ -0,0 +1,101 @@
+// 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.
+
+using System.Text.Json;
+using Google.GenAI.Interactions.Exceptions;
+
+namespace Google.GenAI.Interactions.Core;
+
+///
+/// Helper class for deserializing <c>JsonElement</c> objects. This handles
+/// edge-cases around nullability and reference/value types.
+///
+sealed class WrappedJsonSerializer
+{
+ public static T GetNotNullClass(JsonElement element, string name)
+ where T : class
+ {
+ T deserialized;
+ try
+ {
+ deserialized =
+ JsonSerializer.Deserialize(element, ModelBase.SerializerOptions)
+ ?? throw new GeminiNextGenApiInvalidDataException($"'{name}' cannot be null");
+ }
+ catch (JsonException e)
+ {
+ throw new GeminiNextGenApiInvalidDataException(
+ $"'{name}' must be of type {typeof(T).FullName}",
+ e
+ );
+ }
+ return deserialized;
+ }
+
+ public static T GetNotNullStruct(JsonElement element, string name)
+ where T : struct
+ {
+ T deserialized;
+ try
+ {
+ deserialized =
+ JsonSerializer.Deserialize(element, ModelBase.SerializerOptions)
+ ?? throw new GeminiNextGenApiInvalidDataException($"'{name}' cannot be null");
+ }
+ catch (JsonException e)
+ {
+ throw new GeminiNextGenApiInvalidDataException(
+ $"'{name}' must be of type {typeof(T).FullName}",
+ e
+ );
+ }
+ return deserialized;
+ }
+
+ public static T? GetNullableClass(JsonElement element, string name)
+ where T : class
+ {
+ T? deserialized;
+ try
+ {
+ deserialized = JsonSerializer.Deserialize(element, ModelBase.SerializerOptions);
+ }
+ catch (JsonException e)
+ {
+ throw new GeminiNextGenApiInvalidDataException(
+ $"'{name}' must be of type {typeof(T).FullName}",
+ e
+ );
+ }
+ return deserialized;
+ }
+
+ public static T? GetNullableStruct(JsonElement element, string name)
+ where T : struct
+ {
+ T? deserialized;
+ try
+ {
+ deserialized = JsonSerializer.Deserialize(element, ModelBase.SerializerOptions);
+ }
+ catch (JsonException e)
+ {
+ throw new GeminiNextGenApiInvalidDataException(
+ $"'{name}' must be of type {typeof(T).FullName}",
+ e
+ );
+ }
+ return deserialized;
+ }
+}
diff --git a/Google.GenAI/Interactions/Core/WrappedMultipartJsonSerializer.cs b/Google.GenAI/Interactions/Core/WrappedMultipartJsonSerializer.cs
new file mode 100644
index 00000000..30d8d380
--- /dev/null
+++ b/Google.GenAI/Interactions/Core/WrappedMultipartJsonSerializer.cs
@@ -0,0 +1,107 @@
+// 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.
+
+using System.Text.Json;
+using Google.GenAI.Interactions.Exceptions;
+
+namespace Google.GenAI.Interactions.Core;
+
+///
+/// Helper class for deserializing <c>MultipartJsonElement</c> objects.
+/// This handles edge-cases around nullability and reference/value types.
+///
+sealed class WrappedMultipartJsonSerializer
+{
+ public static T GetNotNullClass(MultipartJsonElement element, string name)
+ where T : class
+ {
+ T deserialized;
+ try
+ {
+ deserialized =
+ MultipartJsonSerializer.Deserialize(element, ModelBase.SerializerOptions)
+ ?? throw new GeminiNextGenApiInvalidDataException($"'{name}' cannot be null");
+ }
+ catch (JsonException e)
+ {
+ throw new GeminiNextGenApiInvalidDataException(
+ $"'{name}' must be of type {typeof(T).FullName}",
+ e
+ );
+ }
+ return deserialized;
+ }
+
+ public static T GetNotNullStruct(MultipartJsonElement element, string name)
+ where T : struct
+ {
+ T deserialized;
+ try
+ {
+ deserialized =
+ MultipartJsonSerializer.Deserialize