ASP.NET Core'a IChatClient Bağlamak: AddChatClient, Keyed Client'lar, Options ve Secret'lar
IChatClient'ı ASP.NET Core'a AddKeyedChatClient ile singleton olarak kaydediyor, "fast" ve "smart" iki model için keyed client, provider ve model seçimi için açılışta doğrulanan options kullanıyoruz. Standard resilience handler'ın 10 sn/30 sn varsayılanlarının LLM çağrısını üç kez baştan ürettirip 500'le bitirdiğini gerçek log'la gösteriyor, timeout ve retry'ı LLM'e göre ayarlıyoruz.
Sürüm notu: Bu yazıdaki kodlar .NET 10 ile derlenip denenmiştir; Microsoft.Extensions.AI 10.11.0, Microsoft.Extensions.AI.OpenAI 10.11.0, OllamaSharp 5.5.2, Microsoft.Extensions.Http.Resilience 10.11.0 ve yerel modelde Ollama 0.34.4 (gemma3:4b, gemma3:12b, nomic-embed-text) kullanılmıştır.
Önce Tahmin Et
Bookstore API'sinde (Application Programming Interface) provider'ın HttpClient'ını IHttpClientFactory ile kaydettin ve ekip kuralı gereği her dış HTTP (Hypertext Transfer Protocol) çağrısına olduğu gibi buna da tek satır ekledin: .AddStandardResilienceHandler(). Hiçbir ayara dokunmadın. Sonra "iki kitabı karşılaştır" endpoint'i, paylaşılan bir makinedeki Ollama'da gemma3:12b'ye streaming olmayan bir istek atıyor ve cevabın tamamı 10 saniyeden uzun sürüyor. Kullanıcı ne görür? Modele kaç istek gider?
Bir de şu: [FromKeyedServices("smart")] IChatClient parametresi her HTTP isteğinde yeni bir client mı alır, yoksa hep aynı instance'ı mı?
İlk sorunun cevabı 30 saniye sonra boş bir 500 ve modele giden üç ayrı istek; aşağıda gerçek log'larıyla göreceğiz. İkinci sorunun cevabını da endpoint'in kendisi söyleyecek.
AddChatClient: Pipeline'ı DI Container'a Taşımak
Önceki yazıda pipeline'ı ChatClientBuilder ile elle kurduk. ASP.NET Core'da aynı builder DI (Dependency Injection) üzerinden gelir: AddChatClient client'ı container'a kaydeder ve bir ChatClientBuilder döner, .Use...() çağrılarını ona eklersin. Varsayılan lifetime singleton; method'un son parametresi ServiceLifetime ve değiştirilebilir ama değiştirmen için iyi bir sebep gerekir. Factory overload'u (sp => ...) client'ı ilk istendiğinde, container'daki diğer servisleri kullanarak oluşturur.
Builder'ın DI'dan gelmesinin bir yan etkisi var: UseLogging() ve UseDistributedCache() gibi middleware'ler parametre verilmezse ihtiyaç duydukları servisi (ILoggerFactory, IDistributedCache) container'dan alır. Container dışında, boş bir service provider ile Build() çağırırsan bunu hata olarak görürsün:
Unhandled exception. System.InvalidOperationException: No service for type 'Microsoft.Extensions.Logging.ILoggerFactory' has been registered.
ASP.NET Core'da ILoggerFactory zaten kayıtlı olduğu için UseLogging() parametresiz çalışır.
İki Model, İki Anahtar: Keyed Client'lar
Bookstore'un iki farklı ihtiyacı var. Ürün sayfasındaki tek cümlelik tanıtım metni ucuz ve hızlı bir modelle yazılabilir; iki kitabı bir junior geliştirici için karşılaştırmak daha güçlü bir model ister. .NET 8 ile gelen keyed service'ler bunun için biçilmiş kaftan: aynı tipte iki kaydı bir anahtarla ayırırsın.
// İki keyed client: ucuz/hızlı ve güçlü/yavaş
builder.Services.AddKeyedChatClient("fast", sp => AiClientFactory.CreateChatClient(sp, o => o.FastModel))
.ConfigureOptions(o => o.Temperature ??= 0)
.UseLogging();
builder.Services.AddKeyedChatClient("smart", sp => AiClientFactory.CreateChatClient(sp, o => o.SmartModel))
.UseLogging();
builder.Services.AddEmbeddingGenerator(AiClientFactory.CreateEmbeddingGenerator);
Her anahtarın kendi pipeline'ı var: "fast" için varsayılan sıcaklık 0, "smart" için çağıranın verdiği ayarlar geçerli. Embedding generator da aynı şekilde AddEmbeddingGenerator ile kaydediliyor; 9. yazıda VectorData bunu kullanacak. Endpoint'ler client'ı anahtarıyla ister:
app.MapGet("/ai/models", (
[FromKeyedServices("fast")] IChatClient fast,
[FromKeyedServices("smart")] IChatClient smart,
IEmbeddingGenerator<string, Embedding<float>> embeddings) => new
{
fast = fast.GetService<ChatClientMetadata>()?.DefaultModelId,
smart = smart.GetService<ChatClientMetadata>()?.DefaultModelId,
embedding = embeddings.GetService<EmbeddingGeneratorMetadata>()?.DefaultModelId,
sameInstance = ReferenceEquals(fast, app.Services.GetRequiredKeyedService<IChatClient>("fast")),
});
app.MapPost("/books/blurb", async (BlurbRequest request, [FromKeyedServices("fast")] IChatClient chat, CancellationToken ct) =>
{
ChatResponse response = await chat.GetResponseAsync(
$"Write a one-sentence shop blurb for the book '{request.Title}'. Reply with the sentence only.", cancellationToken: ct);
return new { model = response.ModelId, text = response.Text.Trim() };
});
app.MapPost("/books/compare", async (CompareRequest request, [FromKeyedServices("smart")] IChatClient chat, CancellationToken ct) =>
{
ChatResponse response = await chat.GetResponseAsync(
$"Compare '{request.First}' and '{request.Second}' for a junior developer in three short bullet points.",
new ChatOptions { Temperature = 0, Seed = 42 }, ct);
return new { model = response.ModelId, text = response.Text.Trim() };
});
curl -s localhost:19620/ai/models
{"fast":"gemma3:4b","smart":"gemma3:12b","embedding":"nomic-embed-text","sameInstance":true}
curl -s -X POST localhost:19620/books/blurb -H 'Content-Type: application/json' -d '{"title":"Domain-Driven Design"}'
{"model":"gemma3:4b","text":"Unlock the secrets to building robust, maintainable software by mastering Domain-Driven Design, Eric Evans’s seminal guide to aligning your code with your business needs."}
sameInstance: true açılıştaki ikinci sorunun cevabı: endpoint'e gelen client, root container'daki singleton'ın aynısı. Pipeline bir kez kurulur ve bütün isteklerde paylaşılır. Bu yüzden middleware'lerin thread-safe olması ve istek başına state'i ChatOptions'ta ya da parametrelerde taşıması gerekir; 2. yazıda options'ı neden kopyaladığımızın asıl sebebi bu.
Bir not: blurb prompt'unun sonundaki "Reply with the sentence only." ilk denemede yoktu. gemma3:4b "tek cümle" isteğine rağmen üç seçenek ve bir takip sorusu döndürdü. Küçük modellerde çıktı biçimini açıkça söylemek gerekiyor.
Provider Seçimi: Options Pattern
Model adları ve provider kodda değil configuration'da durur. appsettings.json:
"Ai": {
"Provider": "Ollama",
"Endpoint": "http://localhost:11434",
"FastModel": "gemma3:4b",
"SmartModel": "gemma3:12b",
"EmbeddingModel": "nomic-embed-text"
}
Bu bölümü strongly typed bir options class'ına bağlayıp açılışta doğruluyoruz:
using System.ComponentModel.DataAnnotations;
namespace Bookstore.Assistant.Api;
public sealed class ChatModelOptions
{
public const string SectionName = "Ai";
[Required, AllowedValues("Ollama", "OpenAI")]
public required string Provider { get; init; }
[Required, Url]
public required string Endpoint { get; init; }
[Required]
public required string FastModel { get; init; }
[Required]
public required string SmartModel { get; init; }
[Required]
public required string EmbeddingModel { get; init; }
// Yalnız bulut provider'ında gerekir; appsettings'e değil, environment variable'a ya da secret store'a konur
public string? ApiKey { get; init; }
}
builder.Services.AddOptions<ChatModelOptions>()
.BindConfiguration(ChatModelOptions.SectionName)
.ValidateDataAnnotations()
.Validate(o => o.Provider != "OpenAI" || !string.IsNullOrWhiteSpace(o.ApiKey),
"Ai:ApiKey is required when Ai:Provider is 'OpenAI'.")
.ValidateOnStart();
Provider'a özel kodun tamamı tek bir static class'ta. Uygulamanın geri kalanı yalnız IChatClient ve IEmbeddingGenerator görür:
using System.ClientModel;
using System.ClientModel.Primitives;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.Options;
using OllamaSharp;
using OpenAI;
namespace Bookstore.Assistant.Api;
public static class AiClientFactory
{
public const string HttpClientName = "ai-provider";
// Provider'a özel kodun tamamı burada; uygulamanın geri kalanı yalnız IChatClient görür
public static IChatClient CreateChatClient(IServiceProvider services, Func<ChatModelOptions, string> modelSelector)
{
ChatModelOptions options = services.GetRequiredService<IOptions<ChatModelOptions>>().Value;
HttpClient http = services.GetRequiredService<IHttpClientFactory>().CreateClient(HttpClientName);
string model = modelSelector(options);
return options.Provider switch
{
"Ollama" => new OllamaApiClient(http, model),
"OpenAI" => new OpenAIClient(
new ApiKeyCredential(options.ApiKey!),
new OpenAIClientOptions { Endpoint = new Uri(options.Endpoint), Transport = new HttpClientPipelineTransport(http) })
.GetChatClient(model)
.AsIChatClient(),
_ => throw new InvalidOperationException($"Unknown provider '{options.Provider}'."),
};
}
public static IEmbeddingGenerator<string, Embedding<float>> CreateEmbeddingGenerator(IServiceProvider services)
{
ChatModelOptions options = services.GetRequiredService<IOptions<ChatModelOptions>>().Value;
HttpClient http = services.GetRequiredService<IHttpClientFactory>().CreateClient(HttpClientName);
return options.Provider switch
{
"Ollama" => new OllamaApiClient(http, options.EmbeddingModel),
"OpenAI" => new OpenAIClient(
new ApiKeyCredential(options.ApiKey!),
new OpenAIClientOptions { Endpoint = new Uri(options.Endpoint), Transport = new HttpClientPipelineTransport(http) })
.GetEmbeddingClient(options.EmbeddingModel)
.AsIEmbeddingGenerator(),
_ => throw new InvalidOperationException($"Unknown provider '{options.Provider}'."),
};
}
}
OpenAI dalı derleniyor ama bu yazıda çalıştırılmadı; API anahtarı kullanmıyoruz. İki dalın ortak noktası, ikisinin de IHttpClientFactory'den gelen aynı adlı HttpClient'ı kullanması. OllamaSharp bu client'ı doğrudan alıyor; OpenAI SDK'sı (Software Development Kit) ise kendi HTTP katmanını System.ClientModel üzerinden kurduğu için client'ı HttpClientPipelineTransport ile sarıp Transport olarak veriyoruz. Böylece timeout ve retry provider'dan bağımsız olarak tek yerde tanımlanıyor.
Model değiştirmek artık bir environment variable. ASP.NET Core'da iç içe anahtarlar çift alt çizgiyle yazılır:
Ai__SmartModel=qwen3:4b dotnet run --urls http://localhost:19621
curl -s localhost:19621/ai/models
{"fast":"gemma3:4b","smart":"qwen3:4b","embedding":"nomic-embed-text","sameInstance":true}
Secret'lar ve Açılışta Doğrulama
Bulut provider'ının API anahtarı appsettings.json'a girmez. Geliştirme makinesinde User Secrets, sunucuda environment variable ya da Azure Key Vault gibi bir secret store kullanılır; anahtar Ai__ApiKey adıyla configuration'a gelir ve ChatModelOptions.ApiKey'e bağlanır. User Secrets'ın nasıl çalıştığını LLM (Large Language Model) Temelleri'nin "Sağlayıcıdan Bağımsız LLM Kodu" yazısında gördük; secret yönetiminin ayrıntısı ilerideki Configuration ve Secret Management serisinde.
Buradaki asıl kazanç ValidateOnStart(). Anahtarı unutulmuş bir deployment, ilk kullanıcı isteğinde 401 almak yerine açılırken düşer:
Ai__Provider=OpenAI Ai__Endpoint=https://api.openai.com/v1 dotnet run --urls http://localhost:19620
fail: Microsoft.Extensions.Hosting.Internal.Host[11]
Hosting failed to start
Microsoft.Extensions.Options.OptionsValidationException: Ai:ApiKey is required when Ai:Provider is 'OpenAI'.
Yanlış yazılmış bir provider adı da aynı şekilde yakalanıyor:
Ai__Provider=Gemini dotnet run --urls http://localhost:19620
fail: Microsoft.Extensions.Hosting.Internal.Host[11]
Hosting failed to start
Microsoft.Extensions.Options.OptionsValidationException: DataAnnotation validation failed for 'ChatModelOptions' members: 'Provider' with the error: 'The Provider field does not equal any of the values specified in AllowedValuesAttribute.'.
Timeout ve Retry: Varsayılanlar LLM'e Göre Değil
Şimdi açılıştaki soruya gelelim. Provider'ın HttpClient'ını şöyle kaydetmiştim:
// Provider'ın HttpClient'ı: timeout ve retry burada yönetilir
builder.Services.AddHttpClient(AiClientFactory.HttpClientName, (sp, http) =>
{
http.BaseAddress = new Uri(sp.GetRequiredService<IOptions<ChatModelOptions>>().Value.Endpoint);
http.Timeout = Timeout.InfiniteTimeSpan; // süreyi resilience handler yönetsin
})
.AddStandardResilienceHandler();
Standard resilience handler, Microsoft Learn'deki tabloya göre dışarıdan içeriye beş strateji zincirler: rate limiter (1000 eşzamanlı istek), 30 saniyelik total timeout, 3 deneme hakkı olan exponential retry, circuit breaker ve 10 saniyelik attempt timeout. Bunlar tipik bir REST (Representational State Transfer) çağrısı için makul. Bir LLM çağrısı içinse değil. /books/compare endpoint'ine attığım isteğin log'u:
Execution attempt. Source: 'ai-provider-standard//Standard-Retry', Operation Key: '', Result: 'The operation didn't complete within the allowed timeout of '00:00:10'.', Handled: 'True', Attempt: '0', Execution Time: 10010,25
Execution attempt. Source: 'ai-provider-standard//Standard-Retry', Operation Key: '', Result: 'The operation didn't complete within the allowed timeout of '00:00:10'.', Handled: 'True', Attempt: '1', Execution Time: 10003,62
Execution attempt. Source: 'ai-provider-standard//Standard-Retry', Operation Key: '', Result: 'The operation was canceled.', Handled: 'False', Attempt: '2', Execution Time: 4832,6666ms
Resilience event occurred. EventName: 'OnTimeout', Source: 'ai-provider-standard//Standard-TotalRequestTimeout', Operation Key: '', Result: ''
fail: Microsoft.Extensions.AI.LoggingChatClient[1784604714]
Polly.Timeout.TimeoutRejectedException: The operation didn't complete within the allowed timeout of '00:00:30'.
Kullanıcı 30 saniye bekleyip gövdesiz bir 500 aldı. Arkada olan daha kötü: gemma3:12b'ye üç ayrı istek gitti, ilk ikisi 10'ar saniye sonra kesildi ve her retry üretimi sıfırdan başlattı. Yerel modelde bu boşa yanan GPU (Graphics Processing Unit) zamanı demek; bulut provider'ında ise kesilen istekler için de token faturası demek, çünkü provider prompt'u işlemeye başlamıştı.
İlk düzeltme denemem yalnız attempt timeout'u 2 dakikaya çıkarmaktı. Uygulama açılmadı:
Microsoft.Extensions.Options.OptionsValidationException: The sampling duration of circuit breaker strategy needs to be at least double of an attempt timeout strategy’s timeout interval, in order to be effective. Sampling Duration: 30s,Attempt Timeout: 120s
Circuit breaker hata oranını bir zaman penceresinde ölçer; pencere tek bir denemeden kısaysa ölçüm anlamsızlaşır ve Polly bunu açılışta reddeder. Son hâli şu:
.AddStandardResilienceHandler(r =>
{
// LLM çağrısı saniyeler değil dakikalar sürebilir: varsayılan 10 sn / 30 sn sınırları burada işe yaramaz
r.AttemptTimeout.Timeout = TimeSpan.FromMinutes(5);
r.TotalRequestTimeout.Timeout = TimeSpan.FromMinutes(6);
// Circuit breaker'ın örnekleme süresi attempt timeout'un en az iki katı olmalı (Polly doğrulaması)
r.CircuitBreaker.SamplingDuration = TimeSpan.FromMinutes(10);
// Yalnız bağlantı hatası, 429 ve 5xx'te tekrar dene; timeout'a uğramış bir üretimi baştan başlatma
r.Retry.ShouldHandle = args => ValueTask.FromResult(args.Outcome switch
{
{ Exception: HttpRequestException } => true,
{ Result.StatusCode: HttpStatusCode.TooManyRequests } => true,
{ Result.StatusCode: >= HttpStatusCode.InternalServerError } => true,
_ => false,
});
});
Retry kararını bilerek daralttım. 429 (rate limit) ve 5xx geçici hatalardır ve provider isteği genelde işlemeden reddetmiştir; tekrar denemek mantıklı. Timeout ise "model çalışıyordu ama yetişemedi" demek; aynı isteği tekrar göndermek aynı süreyi tekrar harcar. 5 dakikalık sınır yerel ve paylaşılan bir Ollama içindir; bulut provider'ında bu değeri provider'ın kendi gecikme ölçümlerine göre koymalısın. Aynı istek bu ayarlarla:
Execution attempt. Source: 'ai-provider-standard//Standard-Retry', Operation Key: '', Result: 'The operation didn't complete within the allowed timeout of '00:05:00'.', Handled: 'False', Attempt: '0', Execution Time: 300020,2408ms
fail: Microsoft.Extensions.AI.LoggingChatClient[1784604714]
GetResponseAsync failed.
Polly.Timeout.TimeoutRejectedException: The operation didn't complete within the allowed timeout of '00:05:00'.
# Ollama sunucu log'u
level=INFO source=sched.go:556 msg="llama-server model predicted to exceed available memory, evicting" predicted="9.0 GiB"
Dürüst olayım: bu makinede gemma3:12b beş dakikada da cevap vermedi. Ollama tek seferde bir istek işleyecek şekilde çalışıyor (OLLAMA_NUM_PARALLEL:1) ve aynı anda başka işler qwen3:4b'yi sürekli kullanıyordu; 9 GiB'lık modeli belleğe almak için diğerlerini boşaltması ve kuyruğun erimesi gerekiyordu. Ama log'daki önemli satır Handled: 'False': timeout'tan sonra retry yok, model bir kez çağrıldı ve kullanıcı tek bir net hata aldı. Daha uzun bir sınır koymak yerine burada doğru cevap, büyük modeli bu kadar kalabalık bir sunucuda çalıştırmamak ya da kullanıcıya hemen "sıradasın" diyen bir streaming endpoint'i sunmak.
Streaming İsteklerde Retry
Streaming çağrıda resilience handler'ın gördüğü şey HTTP cevabının başlıklarıdır, gövdesi değil. Retry yalnız başlıklar gelmeden önceki hatalarda devreye girer: bağlantı kurulamadıysa ya da provider 429 döndüyse. Model token'ları akıtmaya başladıktan sonra bağlantı koparsa handler bunu göremez; hata senin await foreach döngünde exception olarak çıkar. Bu iyi bir şey. Kullanıcıya yarısı gösterilmiş bir cevabı sessizce baştan üretip ikinci bir metni arkasına eklemek, hatayı açıkça bildirmekten çok daha kafa karıştırıcıdır. Yarım cevabı nasıl bildireceğimizi bir sonraki yazıda, Server-Sent Events ile göreceğiz.
Timeout'lar için de aynı sınır geçerli, ve bu beni şaşırttı. Resilience handler bir DelegatingHandler; işi SendAsync dönünce biter. Non-streaming çağrıda Ollama başlıkları cevap tamamlanınca yollar (yukarıdaki blurb isteğinde ilk denemenin süresi 8003 ms, yani üretimin tamamıydı), dolayısıyla timeout bütün üretimi kapsar. Streaming'de ise başlıklar ilk parça hazır olunca gelir. Bunu ayrı bir denemeyle doğruladım: başlıkları hemen gönderip gövdeyi 8 saniyede satır satır akıtan küçük bir test sunucusu ve attempt timeout'u 2, total timeout'u 3 saniye olan bir standard handler:
using HttpResponseMessage res = await http.GetAsync("/", HttpCompletionOption.ResponseHeadersRead);
Console.WriteLine($"headers after {sw.ElapsedMilliseconds} ms");
using StreamReader reader = new(await res.Content.ReadAsStreamAsync());
while (await reader.ReadLineAsync() is string line)
{
Console.WriteLine($"{sw.ElapsedMilliseconds} ms {line}");
}
headers after 27 ms
28 ms chunk0
1027 ms chunk1
2028 ms chunk2
3033 ms chunk3
4043 ms chunk4
5053 ms chunk5
6063 ms chunk6
7073 ms chunk7
Üç saniyelik "total" timeout 7 saniyelik akışı durdurmadı. Streaming bir LLM cevabında bu handler'ın timeout'ları yalnız "ilk token ne kadar sürede gelir" sorusunu sınırlar; akışın toplam süresini sınırlamak istiyorsan bunu uygulama tarafında, isteğin CancellationToken'ına CancelAfter ile bağlı bir süre koyarak yapmalısın.
Lifetime: Singleton Client, Scoped Servis
Client singleton, peki onu kullanan servisler? Bookstore'da sohbet servisi her istekte bir DbContext kullanacak (5. yazıda sohbet geçmişini saklarken). DbContext scoped, dolayısıyla onu kullanan servis de scoped olmalı. Scoped bir servisin singleton IChatClient'a bağımlı olması sorun değil; tersi sorun: singleton bir middleware'in constructor'ında scoped bir servis istemek, ASP.NET Core'un development ortamındaki scope doğrulamasında açılışta hata verir, production'da ise o scoped servisi uygulama boyunca yaşatır. Middleware'in istek başına bir servise ihtiyacı varsa onu IServiceProvider'dan istek anında almalı ya da veriyi ChatOptions.AdditionalProperties ile taşımalı.
Bir de factory'den alınan HttpClient'ın ömrü var. Bizim client'larımız singleton olduğu için factory'den bir kez HttpClient alıp uygulama boyunca tutuyorlar; factory'nin handler'ları periyodik yenilemesi bu instance'a yansımaz. Endpoint'in DNS (Domain Name System) kaydı değişebilecekse Microsoft'un HttpClient rehberi uzun ömürlü client'larda SocketsHttpHandler.PooledConnectionLifetime ayarlamayı öneriyor.
Üretimde Dikkat
Timeout'u gecikme ölçümüne göre koy. "5 dakika" sayısı bu makinedeki paylaşılan Ollama'nın gerçek davranışından geldi. Production'da p99 gecikmesini ölç (8. yazıda OpenTelemetry ile), attempt timeout'u onun biraz üstüne koy ve kullanıcıya uzun işlerde streaming ile ilerleme göster.
429'u ciddiye al. Bulut provider'ları dakika başına istek ve token limitleri uygular ve 429 cevabında genelde Retry-After başlığı döner. Standard handler'ın retry stratejisi bu başlığı dikkate alır; ama retry tek başına yetmez, kendi tarafında da kullanıcı başına sınır koymalısın. Bunu 7. yazıda rate limiting ile yapacağız.
Keyed client sayısını sınırlı tut. "fast" ve "smart" gibi rol adları kullan, model adlarını anahtar yapma; model değiştiğinde endpoint kodu değişmesin. Rol sayısı arttıkça hangi isteğin hangi modele gittiğini telemetry'de etiketlemek önem kazanır.
Sık Düşülen Tuzaklar
Birincisi, her istekte yeni client oluşturmak. Endpoint içinde new OllamaApiClient(...) yazmak her istekte yeni bir HttpClient ve yeni bağlantılar demek; yük altında socket tükenmesine kadar gider ve middleware'leri de her seferinde yeniden kurar. Client'ı bir kez kaydet, DI'dan al.
İkincisi, standard resilience handler'ı LLM provider'ına varsayılan ayarlarla eklemek. Yukarıda gördük: 10 saniyelik attempt timeout ve timeout'ta retry, yavaş bir cevabı üç kez baştan ürettirip 30 saniyede kullanıcıya hata gösterdi.
Üçüncüsü, model adını koda gömmek. "gemma3:12b" string'i endpoint'in içindeyse, model değiştirmek bir deployment demek. Configuration'da dursun, açılışta doğrulansın.
Dördüncüsü, API anahtarını appsettings.Development.json'a "nasılsa sadece geliştirmede" diye yazmak. O dosya repo'ya girer. Anahtar User Secrets'ta ya da environment variable'da durur; repo'ya bir kez girdiyse provider tarafında iptal edilir.
Küçük Bir Deneme
★☆☆ ValidateOnStart() satırını kaldır ve uygulamayı Ai__Provider=Gemini ile başlat. Uygulama açılıyor mu? Hata ne zaman ve hangi istekte çıkıyor?
★★☆ Üçüncü bir keyed client ekle: "tools", model qwen3:4b, pipeline'da UseFunctionInvocation(). Anahtarı ve modeli configuration'dan oku ve /ai/models çıktısına ekle.
★★★ Streaming endpoint'leri için ayrı bir named HttpClient tanımla ("ai-provider-streaming"): retry yalnız 429'da, akışın toplam süresine de uygulama tarafında 15 dakikalık bir sınır koy. Bir IChatClient'ın hangi named client'ı kullanacağına nasıl karar verirsin? İki ayrı keyed client mı, yoksa tek client içinde istek tipine göre seçim mi?
Client'larımız DI'da, ayarları configuration'da ve provider çağrıları makul bir resilience politikasının arkasında. Bir sonraki yazıda cevabı kullanıcıya akıtıyoruz: IAsyncEnumerable döndüren minimal API, .NET 10'un TypedResults.ServerSentEvents'i, kullanıcı sekmeyi kapatınca modeli durdurmak ve yarım kalan cevabı istemciye bildirmek.