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 с кастомным адресом
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:
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:
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 такой вызов оформляется обычным нативным плагином:
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):
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 — таких моделей в каталоге нет:
{"error": {"message": "The model 'gpt-4' does not exist", "code": "model_not_found"}}Список актуальных моделей без ключа:
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, заголовок на нём уже стоит, и повтор уйдёт с тем же ключом:
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
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.
Похожие статьи
- Промпт-инъекция: что это и как защитить бота и агентаОбъясняем, как работает промпт-инъекция через пользовательский ввод и чужие документы, и какие меры снижают ущерб: изоляция данных, права инструментов, лимиты ключа.
- Claude Code YOLO: режимы разрешений и работа без подтвержденийОтдельного режима YOLO в Claude Code нет, зато есть шесть режимов разрешений и гибкие правила. Разбираем, как перестать нажимать «Да» и не лишиться файлов.
- Claude Code: чек-лист лучших практик для ежедневной работыПривычки, которые делают работу с Claude Code предсказуемой: короткий CLAUDE.md, план перед правками, мелкие задачи, проверка результата и контроль расхода.
- MCP-сервер на Java: минимальный пример с одним инструментомПишем минимальный MCP-сервер на Java с одним инструментом, регистрируем его в Claude Code и учимся находить причину, если агент его не видит.