When working with AI model providers through the .NET Semantic Kernel, developers often face scenarios where they need to override or extend the default request configurations. Unfortunately, out-of-the-box support for such customizations is limited, and the library may not expose the desired configuration options. In this blog post, we will explore how to tackle this issue by customizing the HTTP request process.
This tutorial demonstrates how you can intercept and modify HTTP requests with a custom HttpMessageHandler
for Semantic Kernel's model connectors. We'll also walk through a practical example of merging JSON bodies for request payloads.
The Problem: No Default Support for Request Customization
The Semantic Kernel library provides connectors for various AI model providers, like OpenAI, Ollama, and others. However, it lacks a straightforward way to customize request body content. For example, when integrating Ollama's Qwen3 model, you may want to modify the think
property in the request body. Similarly, with other providers like Google's Gemini image generation model, you might need to add custom body fields like response_modalities=["image", "test"]
.
Without direct support, you're left with two options:
- Raise a GitHub issue and hope the maintainers add the feature (which may take weeks or months).
- Take matters into your own hands by customizing the HTTP layer.
This blog focuses on the second option.
The Solution: Customizing HTTP Requests with HttpClientHandler
Semantic Kernel allows you to configure your own HttpClient
when registering a model provider. By injecting a custom HttpClientHandler
, you can intercept and modify requests at runtime.
Code Walkthrough: Implementing RequestOverrideHandler
The RequestOverrideHandler
class extends HttpClientHandler
to enable request modifications. Here's the complete implementation:
type RequestOverrideHandler(?addiotionalBody: string) as this =
inherit HttpClientHandler()
member _.BaseSend(request: HttpRequestMessage, cancellationToken: System.Threading.CancellationToken) = base.SendAsync(request, cancellationToken)
override _.SendAsync(request: HttpRequestMessage, cancellationToken: System.Threading.CancellationToken) = task {
match addiotionalBody with
| Some(SafeString addiotionalBody) ->
let requestClone = new HttpRequestMessage(request.Method, request.RequestUri)
for h in request.Headers do
requestClone.Headers.Add(h.Key, h.Value)
match request.Content with
| null -> requestClone.Content <- new StringContent(addiotionalBody, mediaType = Headers.MediaTypeHeaderValue("application/json"))
| content ->
let! body = content.ReadAsStringAsync(cancellationToken = cancellationToken)
let newContent = new StringContent(mergeJson body addiotionalBody)
for h in content.Headers do
// Avoid overriding Content-Length and Content-Type headers
if h.Key <> "Content-Length" then
if h.Key = "Content-Type" then newContent.Headers.Remove(h.Key) |> ignore
newContent.Headers.Add(h.Key, h.Value)
requestClone.Content <- newContent
return! this.BaseSend(requestClone, cancellationToken)
| _ -> return! this.BaseSend(request, cancellationToken)
}
Key Points:
- The handler intercepts every outgoing HTTP request.
- It optionally overrides the request body with additional JSON content.
- Headers from the original request are preserved, ensuring compatibility with existing configurations.
JSON Merge Logic for Request Body
When modifying the request body, you may need to merge your custom JSON with the existing payload. The mergeJson
function handles this elegantly:
let mergeJson (json1: string) (json2: string) =
let rec loop (obj1: JsonObject) (obj2: JsonObject) =
for kvp in obj2 do
match kvp.Value, obj1.TryGetPropertyValue(kvp.Key) with
// Object into object
| (:? JsonObject as newValue), (true, (:? JsonObject as existingValue)) ->
obj1[kvp.Key] <- loop existingValue (newValue.DeepClone() :?> JsonObject)
// Array merge into array
| (:? JsonArray as newArray), (true, (:? JsonArray as existingArray)) ->
for item in newArray do
existingArray.Add(item.DeepClone())
// If the key does not exist, add it
| _ -> obj1[kvp.Key] <- kvp.Value.DeepClone()
obj1
let options = JsonSerializerOptions.createDefault ()
let mergeObj =
loop (JsonSerializer.Deserialize<JsonObject>(json1, options)) (JsonSerializer.Deserialize<JsonObject>(json2, options))
mergeObj.ToJsonString(options)
This function recursively merges two JSON objects, handling nested objects and arrays.
Creating a Custom HttpClient
With the RequestOverrideHandler
and mergeJson
function in place, you can now configure an HttpClient
to use with Semantic Kernel:
type HttpClient with
static member Create(?headers: Map<string, string>, ?baseUrl: string, ?proxy: string, ?timeoutMs: int, ?addtionalRequestBody: string) =
let httpClientHandler = new RequestOverrideHandler(?addiotionalBody = addtionalRequestBody)
match proxy with
| Some(SafeString proxy) ->
let webProxy = WebProxy(Uri(proxy))
httpClientHandler.UseProxy <- true
httpClientHandler.Proxy <- webProxy
| _ -> ()
let httpClient = new HttpClient(httpClientHandler)
httpClient.Timeout <- TimeSpan.FromMilliseconds(int64 (defaultArg timeoutMs 600_000))
match baseUrl with
| Some(SafeString baseUrl) -> httpClient.BaseAddress <- Uri(if baseUrl.EndsWith "/" then baseUrl else baseUrl + "/")
| _ -> ()
match headers with
| Some headers ->
for KeyValue(key, value) in headers do
httpClient.DefaultRequestHeaders.Add(key, value)
| _ -> ()
httpClient
Registering the Custom HttpClient
with Semantic Kernel
Finally, you can use your customized HttpClient
with Semantic Kernel:
let httpClient = HttpClient.Create(
headers = Some(Map.ofList [("Authorization", "Bearer <your-token>")]),
addtionalRequestBody = Some("""{ "think": false }""")
)
kernelBuilder
.AddOllamaTextGeneration(model.Model, httpClient = httpClient)
.AddOllamaChatCompletion(model.Model, httpClient = httpClient)
.AddOllamaEmbeddingGenerator(model.Model, httpClient = httpClient)
This approach ensures that all outgoing requests for the model provider are routed through your customized HttpClient
.
Summary
In this post, I demonstrated how to overcome the limitations of the .NET Semantic Kernel by customizing HTTP requests. Using a custom HttpMessageHandler
, you can intercept, modify, and extend the request headers and body to meet your specific needs. This method is particularly useful when dealing with unsupported configurations.
Key takeaways:
- Extend
HttpClientHandler
to modify HTTP requests. - Use JSON merging for dynamic request body updates.
- Register the custom
HttpClient
with Semantic Kernel's model connectors.
By following this approach, you can add flexibility and customization to your AI integrations, ensuring that your application remains adaptable to new requirements.
The source code can also be found on https://github.com/slaveOftime/Brainloop. There I created a UI for customize the request body: