slaveOftime

How to extend the request capabilities of the dotnet Semantic Kernel Default Connector

2025-08-08

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:

  1. Raise a GitHub issue and hope the maintainers add the feature (which may take weeks or months).
  2. 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:

request override

Do you like this post?