From 9dc81d85f7f14dbe50d79d85100902d8bb550a17 Mon Sep 17 00:00:00 2001 From: niimima Date: Mon, 9 Mar 2026 14:07:47 +0900 Subject: [PATCH 1/2] Add HttpClientFactory to HttpOptions --- Google.GenAI.Tests/ClientTest.cs | 11 +++++++++++ Google.GenAI.Tests/HttpApiClientTest.cs | 15 +++++++++++++-- Google.GenAI/ApiClient.cs | 6 +++++- Google.GenAI/UploadClient.cs | 3 ++- Google.GenAI/types/HttpOptions.cs | 10 ++++++++++ 5 files changed, 41 insertions(+), 4 deletions(-) diff --git a/Google.GenAI.Tests/ClientTest.cs b/Google.GenAI.Tests/ClientTest.cs index 874170ce..8bb14117 100644 --- a/Google.GenAI.Tests/ClientTest.cs +++ b/Google.GenAI.Tests/ClientTest.cs @@ -15,6 +15,7 @@ */ using System; +using System.Net.Http; using Google.Apis.Auth.OAuth2; using Google.GenAI; @@ -589,6 +590,16 @@ public void Constructor_HttpOptionsProvided_Timeout() { Assert.AreEqual(1000, client._apiClient.HttpOptions.Timeout); } + [TestMethod] + public void Constructor_HttpOptionsProvided_HttpClientFactory() { + Func factory = () => new HttpClient(); + var options = new HttpOptions { HttpClientFactory = factory }; + + var client = new Client(vertexAI: false, apiKey: "key", httpOptions: options); + + Assert.AreSame(factory, client._apiClient.HttpOptions.HttpClientFactory); + } + #endregion #region Successful Instantiation(all modules) diff --git a/Google.GenAI.Tests/HttpApiClientTest.cs b/Google.GenAI.Tests/HttpApiClientTest.cs index fc35a401..f69cf6eb 100644 --- a/Google.GenAI.Tests/HttpApiClientTest.cs +++ b/Google.GenAI.Tests/HttpApiClientTest.cs @@ -15,6 +15,7 @@ */ using System; +using System.Net.Http; using Google.Apis.Auth.OAuth2; @@ -51,6 +52,7 @@ public void GeminiConstructor_WithApiKey_SetsPropertiesCorrectly() { Assert.AreEqual("https://generativelanguage.googleapis.com", client.HttpOptions.BaseUrl); Assert.AreEqual("v1beta", client.HttpOptions.ApiVersion); Assert.IsNull(client.HttpOptions.Timeout); + Assert.IsNull(client.HttpOptions.HttpClientFactory); } [TestMethod] @@ -67,13 +69,16 @@ public void GeminiConstructor_WithApiKeyFromEnvironment_SetsPropertiesCorrectly( Assert.AreEqual("https://generativelanguage.googleapis.com", client.HttpOptions.BaseUrl); Assert.AreEqual("v1beta", client.HttpOptions.ApiVersion); Assert.IsNull(client.HttpOptions.Timeout); + Assert.IsNull(client.HttpOptions.HttpClientFactory); } [TestMethod] public void GeminiConstructor_WithHttpOptionsProvided_SetsPropertiesCorrectly() { System.Environment.SetEnvironmentVariable(EnvApiKeyName, TestApiKey); + var httpClientFactory = () => new HttpClient(); var customHttpOptions = new Types.HttpOptions { BaseUrl = "https://custom-url.com", - ApiVersion = "v2", Timeout = 6000 }; + ApiVersion = "v2", Timeout = 6000, + HttpClientFactory = httpClientFactory }; var client = new HttpApiClient(null, customHttpOptions); @@ -85,6 +90,7 @@ public void GeminiConstructor_WithHttpOptionsProvided_SetsPropertiesCorrectly() Assert.AreEqual("https://custom-url.com", client.HttpOptions.BaseUrl); Assert.AreEqual("v2", client.HttpOptions.ApiVersion); Assert.AreEqual(6000, client.HttpOptions.Timeout); + Assert.AreSame(httpClientFactory, client.HttpOptions.HttpClientFactory); } [TestMethod] @@ -124,6 +130,7 @@ public void VertexConstructor_WithProjectLocationCredentials_SetsPropertiesCorre client.HttpOptions.BaseUrl); Assert.AreEqual("v1beta1", client.HttpOptions.ApiVersion); Assert.IsNull(client.HttpOptions.Timeout); + Assert.IsNull(client.HttpOptions.HttpClientFactory); } [TestMethod] @@ -147,6 +154,7 @@ public void VertexConstructor_WithProjectLocationFromEnvironment_SetsPropertiesC client.HttpOptions.BaseUrl); Assert.AreEqual("v1beta1", client.HttpOptions.ApiVersion); Assert.IsNull(client.HttpOptions.Timeout); + Assert.IsNull(client.HttpOptions.HttpClientFactory); } [TestMethod] @@ -214,8 +222,10 @@ public void VertexConstructor_WithCustomHttpOptions_OverridesDefaults() { .Setup(c => c.GetAccessTokenForRequestAsync( It.IsAny(), It.IsAny())) .ReturnsAsync("mock-access-token"); + var httpClientFactory = () => new HttpClient(); var customOptions = new Types.HttpOptions { BaseUrl = "https://custom.vertex.ai", - ApiVersion = "v2alpha", Timeout = 8000 }; + ApiVersion = "v2alpha", Timeout = 8000, + HttpClientFactory = httpClientFactory }; var client = new HttpApiClient(TestProject, TestLocation, mockCredential.Object, customOptions); @@ -228,6 +238,7 @@ public void VertexConstructor_WithCustomHttpOptions_OverridesDefaults() { Assert.AreEqual("https://custom.vertex.ai", client.HttpOptions.BaseUrl); Assert.AreEqual("v2alpha", client.HttpOptions.ApiVersion); Assert.AreEqual(8000, client.HttpOptions.Timeout); + Assert.AreSame(httpClientFactory, client.HttpOptions.HttpClientFactory); } } } diff --git a/Google.GenAI/ApiClient.cs b/Google.GenAI/ApiClient.cs index ab89adce..ccc43644 100644 --- a/Google.GenAI/ApiClient.cs +++ b/Google.GenAI/ApiClient.cs @@ -126,7 +126,7 @@ protected ApiClient( private static HttpClient CreateHttpClient(HttpOptions httpOptions) { - var client = new HttpClient(); + var client = httpOptions.HttpClientFactory?.Invoke() ?? new HttpClient(); if (httpOptions.Timeout != null) { client.Timeout = System.TimeSpan.FromMilliseconds(httpOptions.Timeout.Value); @@ -201,6 +201,10 @@ protected HttpOptions MergeHttpOptions(HttpOptions? optionsToApply) { mergedOptions.Timeout = optionsToApply?.Timeout; } + if (optionsToApply?.HttpClientFactory != null) + { + mergedOptions.HttpClientFactory = optionsToApply?.HttpClientFactory; + } var currentHeaders = this.HttpOptions.Headers ?? new Dictionary(); var newHeaders = optionsToApply?.Headers ?? new Dictionary(); diff --git a/Google.GenAI/UploadClient.cs b/Google.GenAI/UploadClient.cs index ffc81720..792ca83f 100644 --- a/Google.GenAI/UploadClient.cs +++ b/Google.GenAI/UploadClient.cs @@ -86,7 +86,8 @@ public static HttpOptions BuildResumableUploadHttpOptions( ApiVersion = "", Headers = mergedHeaders, BaseUrl = userOptions?.BaseUrl, - Timeout = userOptions?.Timeout + Timeout = userOptions?.Timeout, + HttpClientFactory = userOptions?.HttpClientFactory }; } diff --git a/Google.GenAI/types/HttpOptions.cs b/Google.GenAI/types/HttpOptions.cs index b8fbffce..31f6f98f 100644 --- a/Google.GenAI/types/HttpOptions.cs +++ b/Google.GenAI/types/HttpOptions.cs @@ -64,6 +64,16 @@ public int get; set; } + /// + /// A factory function to create HttpClient instances. + /// This allows for custom configuration of HttpClient, such as setting default headers, timeouts, or using a custom message handler. + /// + [JsonIgnore] + public Func + ? HttpClientFactory { + get; set; + } + /// /// Deserializes a JSON string to a HttpOptions object. /// From 001d5c526a470adb84e51b2a1a9b017293ac1557 Mon Sep 17 00:00:00 2001 From: niimima Date: Fri, 13 Mar 2026 19:04:42 +0900 Subject: [PATCH 2/2] fix: remove duplicated tests introduced during merge --- Google.GenAI.Tests/HttpApiClientTest.cs | 149 ------------------------ 1 file changed, 149 deletions(-) diff --git a/Google.GenAI.Tests/HttpApiClientTest.cs b/Google.GenAI.Tests/HttpApiClientTest.cs index 07b3af39..f50506cd 100644 --- a/Google.GenAI.Tests/HttpApiClientTest.cs +++ b/Google.GenAI.Tests/HttpApiClientTest.cs @@ -387,154 +387,5 @@ public async Task CreateHttpRequestAsync_RewritesAbsoluteUrlInPathWithBaseUrl() Assert.AreEqual("https://my-proxy.company.com/v1beta/models/gemini-3.0-flash:generateContent", request.RequestUri.ToString()); } - - [TestMethod] - public void VertexConstructor_WithCustomHttpOptions_NoGoogleApisEnv_ClearsAuthIfInsufficient() { - var customOptions = new Types.HttpOptions { BaseUrl = "https://my-proxy.company.internal" }; - - var client = - new HttpApiClient(vertexAI: true, httpOptions: customOptions); - - Assert.IsNull(client.Project); - Assert.IsNull(client.Location); - Assert.IsNull(client.ApiKey); - Assert.IsNull(client.Credentials); - Assert.IsTrue(client.VertexAI); - Assert.AreEqual("https://my-proxy.company.internal", client.HttpOptions.BaseUrl); - } - - [TestMethod] - public void VertexConstructor_WithApiKey_VertexExpress_ClearsProjectEnvVar() { - System.Environment.SetEnvironmentVariable(EnvProjectName, "ignored-project"); - System.Environment.SetEnvironmentVariable(EnvLocationName, "ignored-location"); - - var client = new HttpApiClient(vertexAI: true, apiKey: "express-key"); - - Assert.AreEqual("express-key", client.ApiKey); - Assert.IsNull(client.Project); - Assert.IsNull(client.Location); - Assert.IsNull(client.Credentials); - Assert.IsTrue(client.VertexAI); - Assert.AreEqual("https://aiplatform.googleapis.com", client.HttpOptions.BaseUrl); - } - - [TestMethod] - public void VertexConstructor_LocationGlobal_SetsCorrectBaseUrl() { - var mockCredential = new Mock(); - var client = new HttpApiClient(vertexAI: true, project: "my-project", location: "global", credentials: mockCredential.Object); - - Assert.AreEqual("my-project", client.Project); - Assert.AreEqual("global", client.Location); - Assert.IsTrue(client.VertexAI); - Assert.AreEqual("https://aiplatform.googleapis.com", client.HttpOptions.BaseUrl); - } - - [TestMethod] - public void Constructor_HttpOptions_BaseUrlResourceScopeWithoutBaseUrl_ThrowsArgumentException() { - var customOptions = new Types.HttpOptions { BaseUrlResourceScope = Types.ResourceScope.Collection, BaseUrl = null }; - var ex = Assert.ThrowsException(() => new HttpApiClient(vertexAI: false, apiKey: "key", httpOptions: customOptions)); - Assert.IsTrue(ex.Message.Contains("base_url must be set when base_url_resource_scope is set.")); - } - - private async System.Threading.Tasks.Task InvokeCreateHttpRequestAsync( - HttpApiClient client, System.Net.Http.HttpMethod method, string path, string content, Types.HttpOptions? options) - { - var methodInfo = typeof(HttpApiClient).GetMethod("CreateHttpRequestAsync", - System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance, - null, - new[] { typeof(System.Net.Http.HttpMethod), typeof(string), typeof(string), typeof(Types.HttpOptions) }, - null); - - var task = (System.Threading.Tasks.Task)methodInfo!.Invoke(client, new object?[] { method, path, content, options })!; - return await task; - } - - [TestMethod] - public async System.Threading.Tasks.Task CreateHttpRequestAsync_Vertex_ResourceScopeCollection_OmitsApiVersion() { - var customOptions = new Types.HttpOptions { BaseUrl = "https://custom.vertex.ai", BaseUrlResourceScope = Types.ResourceScope.Collection }; - var mockCredential = new Mock(); - mockCredential - .Setup(c => c.GetAccessTokenForRequestAsync( - It.IsAny(), It.IsAny())) - .ReturnsAsync("mock-access-token"); - var client = new HttpApiClient(vertexAI: true, httpOptions: customOptions, credentials: mockCredential.Object); - - var request = await InvokeCreateHttpRequestAsync(client, System.Net.Http.HttpMethod.Post, "some/path", "{}", null); - - Assert.AreEqual("https://custom.vertex.ai/some/path", request.RequestUri!.ToString()); - } - - [TestMethod] - public async System.Threading.Tasks.Task CreateHttpRequestAsync_Vertex_NullProjectLocation_DoesNotPrependPath() { - // Use an explicit base URL and custom options to avoid default location/project requirement. - var customOptions = new Types.HttpOptions { BaseUrl = "https://my-proxy.company.internal" }; - var mockCredential = new Mock(); - mockCredential - .Setup(c => c.GetAccessTokenForRequestAsync( - It.IsAny(), It.IsAny())) - .ReturnsAsync("mock-access-token"); - var client = new HttpApiClient(vertexAI: true, httpOptions: customOptions, credentials: mockCredential.Object); - - var request = await InvokeCreateHttpRequestAsync(client, System.Net.Http.HttpMethod.Post, "some/path", "{}", null); - - // Should not prepend projects/{project}/locations/{location} because they are null/empty. - Assert.AreEqual("https://my-proxy.company.internal/v1beta1/some/path", request.RequestUri!.ToString()); - } - - [TestMethod] - public async System.Threading.Tasks.Task CreateHttpRequestAsync_Vertex_WithProjectLocation_PrependsPath() { - var mockCredential = new Mock(); - mockCredential - .Setup(c => c.GetAccessTokenForRequestAsync( - It.IsAny(), It.IsAny())) - .ReturnsAsync("mock-access-token"); - var client = new HttpApiClient(vertexAI: true, project: "my-project", location: "my-location", credentials: mockCredential.Object); - - var request = await InvokeCreateHttpRequestAsync(client, System.Net.Http.HttpMethod.Post, "some/path", "{}", null); - - Assert.AreEqual("https://my-location-aiplatform.googleapis.com/v1beta1/projects/my-project/locations/my-location/some/path", request.RequestUri!.ToString()); - } - - [TestMethod] - public async Task CreateHttpRequestAsync_RewritesAbsoluteUrlWithBaseUrl() { - var customHttpOptions = new Types.HttpOptions { BaseUrl = "https://my-proxy.company.com" }; - var client = new HttpApiClient(apiKey: TestApiKey, httpOptions: customHttpOptions); - - var method = typeof(HttpApiClient).GetMethod("CreateHttpRequestAsync", - System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance, - null, - new[] { typeof(System.Net.Http.HttpMethod), typeof(string), typeof(byte[]), typeof(Types.HttpOptions) }, - null); - Assert.IsNotNull(method, "Could not find CreateHttpRequestAsync method via reflection."); - - var absoluteUrl = "https://generativelanguage.googleapis.com/upload/v1beta/files?uploadType=resumable"; - var bytes = new byte[] { 1, 2, 3 }; - - var task = (Task)method.Invoke(client, new object[] { System.Net.Http.HttpMethod.Post, absoluteUrl, bytes, null }); - var request = await task; - - Assert.AreEqual("https://my-proxy.company.com/upload/v1beta/files?uploadType=resumable", request.RequestUri.ToString()); - } - - [TestMethod] - public async Task CreateHttpRequestAsync_RewritesAbsoluteUrlInPathWithBaseUrl() { - var customHttpOptions = new Types.HttpOptions { BaseUrl = "https://my-proxy.company.com" }; - var client = new HttpApiClient(apiKey: TestApiKey, httpOptions: customHttpOptions); - - var method = typeof(HttpApiClient).GetMethod("CreateHttpRequestAsync", - System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance, - null, - new[] { typeof(System.Net.Http.HttpMethod), typeof(string), typeof(string), typeof(Types.HttpOptions) }, - null); - Assert.IsNotNull(method, "Could not find CreateHttpRequestAsync method via reflection."); - - var absoluteUrl = "https://generativelanguage.googleapis.com/v1beta/models/gemini-3.0-flash:generateContent"; - var json = "{}"; - - var task = (Task)method.Invoke(client, new object[] { System.Net.Http.HttpMethod.Post, absoluteUrl, json, null }); - var request = await task; - - Assert.AreEqual("https://my-proxy.company.com/v1beta/models/gemini-3.0-flash:generateContent", request.RequestUri.ToString()); - } } }