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
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:
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:
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:
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):
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:
{"error": {"message": "The model 'gpt-4' does not exist", "code": "model_not_found"}}Check the current list without a key:
curl https://api.zukko.pro/v1/modelsPer-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:
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
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.
Related articles
- Prompt Injection: What It Is and How to Protect a Bot and an AgentHow prompt injection works through user input and third-party documents, and which measures limit the damage: data isolation, tool permissions, key limits.
- Claude Code YOLO: Permission Modes and Working Without PromptsClaude Code has no mode literally called YOLO, but it has several permission modes and flexible rules. How to stop clicking Yes without losing files.
- Claude Code: a checklist of best practices for daily workHabits that make working with Claude Code predictable: a short CLAUDE.md, a plan before edits, small tasks, checking results and keeping spend under control.
- An MCP server in Java: a minimal example with one toolWe write a minimal MCP server in Java with one tool, register it in Claude Code, and learn to find the cause when the agent doesn't see it.