Semantic Kernel: connecting a .NET app to Claude and GPT

SK talks to OpenAI through the official .NET SDK, which honors HttpClient.BaseAddress. Setup, the trailing-slash 404, and double-charged retries.

5 min · · Updated · агенты · development

Semantic Kernel in .NET talks to OpenAI through the official OpenAI SDK, which in turn honors HttpClient.BaseAddress when you pass an HttpClient explicitly. Pointing SK at a different OpenAI-compatible backend is a one-line change in the Kernel setup, no package swap required.

The specifics of the .NET ecosystem mean most connection problems here have nothing to do with the gateway itself — they come from how HttpClient and Uri build the final address, plus the fact that enterprise projects usually pull configuration from appsettings.json or environment variables through IConfiguration rather than hardcoding it. Below is a working setup and the three places where first-connection time typically gets lost.

Setting up the Kernel with a custom address

bash
using Microsoft.SemanticKernel;

var httpClient = new HttpClient
{
    BaseAddress = new Uri("https://api.zukko.pro/v1/"),
};

var builder = Kernel.CreateBuilder();
builder.AddOpenAIChatCompletion(
    modelId: "gpt-5.6-sol",
    apiKey: "fetch_your_key",
    httpClient: httpClient);

var kernel = builder.Build();

var result = await kernel.InvokePromptAsync("Draft a .NET 9 migration checklist");
Console.WriteLine(result);

Note the trailing slash in BaseAddress: https://api.zukko.pro/v1/, not https://api.zukko.pro/v1. HttpClient combines the base address with a relative path following Uri rules, and without the trailing slash the last path segment (v1) sometimes gets dropped during the merge — the request lands on https://api.zukko.pro/chat/completions instead of .../v1/chat/completions, and you get a 404. It is not a gateway bug, it is System.Uri's address-combining behavior, but it looks exactly like a bad key.

Multiple models in one Kernel

AddOpenAIChatCompletion can be called several times with different serviceId values, and the right service picked via PromptExecutionSettings:

bash
builder.AddOpenAIChatCompletion(
    modelId: "claude-opus-5-5",
    apiKey: "fetch_your_key",
    httpClient: httpClient,
    serviceId: "opus");

builder.AddOpenAIChatCompletion(
    modelId: "glm-5.3",
    apiKey: "fetch_your_key",
    httpClient: httpClient,
    serviceId: "glm");

var settings = new OpenAIPromptExecutionSettings { ServiceId = "opus" };

Both Claude Opus 5.5 and every other model in the catalogue answer in the same OpenAI-compatible /v1/chat/completions shape, so switching models is just changing modelId — the call protocol stays the same.

Images in a message

The Claude and GPT models in the catalogue accept images (GLM has no vision), and in Semantic Kernel that's a plain ChatMessageContentItemCollection:

bash
var chatHistory = new ChatHistory();
chatHistory.AddUserMessage(new ChatMessageContentItemCollection
{
    new TextContent("What's wrong with this screenshot?"),
    new ImageContent(new Uri(screenshotUrl)),
});

var chat = kernel.GetRequiredService<IChatCompletionService>();
var reply = await chat.GetChatMessageContentAsync(chatHistory, settings, kernel);

It works the same for gpt-5.6-sol and for claude-opus-5-5 — the message format doesn't depend on the model. Worth remembering that such a call is billed under the usual text-model token rules; there's no separate charge just for a message containing an image.

Web search as a Kernel plugin

None of the catalogue's models have built-in web search — that lives behind a separate POST /v1/web-search. In Semantic Kernel that call is a plain native plugin:

bash
public class WebSearchPlugin
{
    private readonly HttpClient _http;
    public WebSearchPlugin(HttpClient http) => _http = http;

    [KernelFunction, Description("Looks up current information online")]
    public async Task<string> SearchAsync(string query)
    {
        var response = await _http.PostAsJsonAsync(
            "https://api.zukko.pro/v1/web-search", new { query });
        return await response.Content.ReadAsStringAsync();
    }
}

kernel.Plugins.AddFromObject(new WebSearchPlugin(httpClient), "web");

It's a separate billed call, not part of the chat request — it shows up in GET /v1/usage as its own line with its own cost.

Logging cost through a DelegatingHandler

The same HttpClient already configured with BaseAddress is a convenient place to log spend — the X-Zukko-Cost-Micros and X-Fetch-Balance-Cents headers arrive on every regular (non-streaming) response, and one handler can catch them for the whole Kernel. Streamed responses don't carry them; X-Fetch-Balance-Cents, despite the name, is in micro-dollars (1,000,000 = $1):

bash
public class CostLoggingHandler : DelegatingHandler
{
    protected override async Task<HttpResponseMessage> SendAsync(
        HttpRequestMessage request, CancellationToken ct)
    {
        var response = await base.SendAsync(request, ct);
        if (response.Headers.TryGetValues("X-Zukko-Cost-Micros", out var cost))
            _logger.LogInformation("Request cost {Cost} micro-dollars", cost.First());
        return response;
    }
}

That saves a manual trip to GET /v1/usage just to see what a specific call from a specific plugin cost — the number lands in the same logs as the rest of the application's telemetry.

A 401 with a correct key

If the project previously ran on Azure OpenAI, it's easy to leave a stray AddAzureOpenAIChatCompletion call instead of AddOpenAIChatCompletion — the Azure variant builds a different endpoint structure and a different header set, and the gateway answers 401 Invalid API key even though the fetch_... key itself is correct. The tell is that the error appears instantly, before any network timeout, because the SDK builds an entirely different URL from the start.

Model not found

Older Semantic Kernel samples from Microsoft's docs often reference gpt-4 or gpt-35-turbo — neither exists in this catalogue:

bash
{"error": {"message": "The model 'gpt-4' does not exist", "code": "model_not_found"}}

Check the current list without a key:

bash
curl https://api.zukko.pro/v1/models

Per-environment key limits

A separate key per environment also guards against a bug in the code itself, not just a leak: a retry loop in CI integration tests that would never trigger in production hits 429 quickly and visibly on a dev key with a small daily limit, instead of quietly burning through the budget overnight.

For a .NET app with several environments (dev/staging/prod), it helps to issue a separate fetch_... key per environment, each with its own daily spend limit set on the key's dashboard page. That way a leaked dev key or a runaway test loop never touches the production budget.

Idempotency under Polly retries

If HttpClient is wrapped in a Polly retry policy (a common ASP.NET pattern), a retry triggered by a timeout can double-charge a call. Add a DelegatingHandler that stamps an Idempotency-Key once per original request and is registered inside (after) the Polly handler — Polly resends the same HttpRequestMessage, the header is already on it, and the retry goes out with the same key:

bash
public class IdempotencyHandler : DelegatingHandler
{
    protected override Task<HttpResponseMessage> SendAsync(
        HttpRequestMessage request, CancellationToken ct)
    {
        if (!request.Headers.Contains("Idempotency-Key"))
            request.Headers.Add("Idempotency-Key", Guid.NewGuid().ToString());
        return base.SendAsync(request, ct);
    }
}

Generate the idempotency key once per logical request, not once per retry attempt — otherwise a retry just charges again under a fresh key. For the same reason, don't set one fixed key for the whole HttpClient (via DefaultRequestHeaders): the very next request with a different body would get 422 idempotency_conflict.

Check before starting the Kernel

bash
curl https://api.zukko.pro/v1/chat/completions \
  -H "Authorization: Bearer fetch_your_key" \
  -H "Content-Type: application/json" \
  -d '{"model":"gpt-5.6-sol","messages":[{"role":"user","content":"ping"}]}'

If curl answers but the Kernel still fails, the problem is in HttpClient.BaseAddress or a mixed-up registration method. Balance and spend history are available through GET /v1/balance and GET /v1/usage, and keys are issued in the dashboard at https://zukko.pro/app/api-keys. If the key comes from IConfiguration and suddenly reads as an empty string in production but not locally, check first that the environment variable is actually declared in the deployment manifest, not just in a developer's .env file that never makes it into the container.

zukko

Pay 25×
less

for Claude Code & ChatGPT

One fetch_* key and base URL api.zukko.pro — for Cursor, Claude Code, Codex, and any SDK.

Get API keyDocumentation

api.zukko.pro · fetch_*

Semantic Kernel with Claude and GPT — HttpClient setup in .NET · Zukko