Semantic Kernel: подключение .NET-приложения к Claude и GPT

SK ходит к OpenAI через официальный .NET SDK, который слушается HttpClient.BaseAddress. Разбираем настройку, частые 404 и повторные списания.

5 мин · · Обновлено · агенты · разработка

Semantic Kernel в .NET ходит к OpenAI через официальный OpenAI SDK, а тот, в свою очередь, слушается HttpClient.BaseAddress, если передать HttpClient явно. Переключить SK на другой OpenAI-совместимый бэкенд — вопрос одной строки в конфигурации Kernel, без смены пакетов.

Специфика .NET-экосистемы в том, что большая часть проблем с подключением здесь не про сам гейтвей, а про поведение HttpClient и Uri при сборке адреса, плюс про то, что в корпоративных проектах конфигурация обычно приходит из appsettings.json или переменных окружения через IConfiguration, а не пишется в коде напрямую. Ниже — рабочая настройка и три места, где типично теряется время при первом подключении.

Настройка Kernel с кастомным адресом

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_ваш_ключ",
    httpClient: httpClient);

var kernel = builder.Build();

var result = await kernel.InvokePromptAsync("Составь чек-лист миграции на .NET 9");
Console.WriteLine(result);

Обратите внимание на завершающий слеш в BaseAddress: https://api.zukko.pro/v1/, а не https://api.zukko.pro/v1. HttpClient комбинирует базовый адрес с относительным путём по правилам Uri, и без слеша на конце последний сегмент пути (v1) при склейке иногда обрезается — запрос улетает на https://api.zukko.pro/chat/completions вместо .../v1/chat/completions, и в ответ приходит 404. Это не ошибка гейтвея, а особенность System.Uri.TryCreate при объединении адресов, но выглядит она ровно как "ключ не тот".

Несколько моделей в одном Kernel

AddOpenAIChatCompletion можно вызывать несколько раз с разными serviceId, а нужный сервис выбирать через PromptExecutionSettings:

bash
builder.AddOpenAIChatCompletion(
    modelId: "claude-opus-5-5",
    apiKey: "fetch_ваш_ключ",
    httpClient: httpClient,
    serviceId: "opus");

builder.AddOpenAIChatCompletion(
    modelId: "glm-5.3",
    apiKey: "fetch_ваш_ключ",
    httpClient: httpClient,
    serviceId: "glm");

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

И Claude Opus 5.5, и остальные модели каталога отвечают в этом же OpenAI-совместимом формате /v1/chat/completions, поэтому переключение модели — это просто смена modelId, без смены протокола вызова.

Изображения в сообщении

Модели Claude и GPT в каталоге принимают картинки (у GLM vision нет), и в Semantic Kernel это обычный ChatMessageContentItemCollection:

bash
var chatHistory = new ChatHistory();
chatHistory.AddUserMessage(new ChatMessageContentItemCollection
{
    new TextContent("Что не так на этом скриншоте?"),
    new ImageContent(new Uri(screenshotUrl)),
});

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

Работает одинаково что для gpt-5.6-sol, что для claude-opus-5-5 — формат сообщения от модели не зависит. Стоит помнить, что счёт за такой вызов идёт по обычным правилам токенов текстовой модели, отдельного тарифа за само наличие картинки в запросе нет.

Веб-поиск как плагин Kernel

У моделей каталога нет встроенного поиска в интернете — за это отвечает отдельный POST /v1/web-search. В Semantic Kernel такой вызов оформляется обычным нативным плагином:

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

    [KernelFunction, Description("Ищет актуальную информацию в интернете")]
    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");

Это отдельный оплачиваемый вызов, а не часть чат-запроса — в истории списаний (GET /v1/usage) он появится отдельной строкой с собственной стоимостью.

Логирование стоимости через DelegatingHandler

Тот же HttpClient, который уже настроен на BaseAddress, удобно использовать для логирования расхода — заголовки X-Zukko-Cost-Micros и X-Fetch-Balance-Cents приходят в каждом обычном (нестриминговом) ответе, и их можно перехватить одним хендлером на весь Kernel. У потоковых ответов этих заголовков нет; X-Fetch-Balance-Cents, несмотря на название, — в микродолларах (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("Запрос стоил {Cost} микродолларов", cost.First());
        return response;
    }
}

Это избавляет от необходимости ходить в GET /v1/usage вручную, чтобы понять, сколько стоил конкретный вызов из конкретного плагина — цифра сразу оказывается в тех же логах, что и остальная телеметрия приложения.

401 при правильном ключе

Если в проекте раньше был настроен Azure OpenAI, легко случайно оставить вызов AddAzureOpenAIChatCompletion вместо AddOpenAIChatCompletion — у Azure-варианта другая структура эндпоинта и другой набор заголовков, и гейтвей ответит 401 Invalid API key, хотя сам ключ fetch_... вставлен верно. Симптом — ошибка появляется мгновенно, ещё до сетевого таймаута, потому что SDK формирует заведомо другой URL.

Модель не найдена

Старые примеры Semantic Kernel из документации Microsoft часто используют gpt-4 или gpt-35-turbo — таких моделей в каталоге нет:

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

Список актуальных моделей без ключа:

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

Лимиты на ключ по окружениям

Отдельный ключ на окружение — это ещё и способ защититься от бага в самом коде, а не только от утечки: зацикленный ретрай в интеграционных тестах на CI, который в проде никогда бы не сработал, на dev-ключе с небольшим дневным лимитом упрётся в 429 быстро и заметно, а не тихо съест бюджет за ночь.

Для .NET-приложения с несколькими окружениями (dev/staging/prod) удобно завести отдельный fetch_... ключ под каждое, с собственным дневным лимитом расхода — это настраивается в кабинете на странице ключа. Так утечка ключа из dev-конфига или бесконечный цикл в тестах не тронет прод-бюджет.

Idempotency при ретраях Polly

Если HttpClient обёрнут в Polly-политику с автоматическими ретраями (частый паттерн в ASP.NET), повтор из-за таймаута может задвоить списание. Добавьте DelegatingHandler, который проставляет Idempotency-Key один раз на исходный запрос и регистрируется внутри (после) Polly-хендлера — Polly повторно отправляет тот же HttpRequestMessage, заголовок на нём уже стоит, и повтор уйдёт с тем же ключом:

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);
    }
}

Ключ идемпотентности нужно генерировать один раз на логический запрос, а не на каждую попытку ретрая — иначе повтор просто спишет заново с новым ключом. По той же причине не задавайте один фиксированный ключ на весь HttpClient (через DefaultRequestHeaders): тогда второй же запрос с другим телом получит 422 idempotency_conflict.

Проверка перед запуском Kernel

bash
curl https://api.zukko.pro/v1/chat/completions \
  -H "Authorization: Bearer fetch_ваш_ключ" \
  -H "Content-Type: application/json" \
  -d '{"model":"gpt-5.6-sol","messages":[{"role":"user","content":"ping"}]}'

Если curl отвечает, а Kernel всё ещё падает — дело в HttpClient.BaseAddress или в перепутанном методе регистрации сервиса. Баланс и списания видно через GET /v1/balance и GET /v1/usage, ключи выпускаются в кабинете https://zukko.pro/app/api-keys. Если ключ читается из IConfiguration и внезапно оказывается пустой строкой на проде, но не на локальной машине — первым делом проверьте, что переменная окружения действительно объявлена в манифесте деплоя, а не только в .env-файле разработчика, который в контейнер не попадает.

zukko

Платите в 25×
меньше

за Claude Code & ChatGPT

Один ключ fetch_* и base URL api.zukko.pro — в Cursor, Claude Code, Codex и любом SDK.

Semantic Kernel + Claude и GPT — настройка HttpClient в .NET · Zukko