Sağlayıcıdan Bağımsız LLM Kodu: IChatClient, OpenAI Uyumlu /v1 ve DI
IChatClient soyutlamasıyla aynı kodu Ollama'nın kendi API'si ve OpenAI uyumlu /v1 endpoint'i üzerinden çalıştırıyor, sağlayıcıyı yapılandırmadan ve ortam değişkeniyle değiştiriyoruz. ASP.NET Core minimal API'ye DI ile bağlıyor, API anahtarlarını user secrets ve ortam değişkenleriyle saklıyor, TypeScript'te openai paketiyle aynı fikri kuruyor ve soyutlamanın sınırlarına bakıyoruz.
Sürüm notu: Bu yazıdaki örnekler Ollama 0.34 ile yerel modelde (gemma3:4b) denenmiştir; .NET 10 (Microsoft.Extensions.AI 10.11.0, Microsoft.Extensions.AI.OpenAI 10.11.0, OllamaSharp 5.5.2) ve Node 22 (ollama 0.6.4, openai 7.53.0) kullanılmıştır.
Önce Tahmin Et
Bookstore asistanı geliştirme ortamında Ollama'yla çalışıyor. Ürün ortamında ise bulut tarafında bir model kullanmaya karar verildi. Kodunda kaç satırı değiştirmen gerekir? Bir de şunu düşün: Ollama'yı resmî OpenAI SDK'sıyla çağırabilir misin, yoksa iki ayrı kod mu yazman gerekir?
Bu yazının sonunda ilk sorunun cevabı "sıfır satır, bir ortam değişkeni" olacak. İkinci sorunun cevabını da gerçek bir çağrıyla göreceğiz.
Soyutlama: IChatClient
Serinin başından beri C# örneklerinde hep aynı tipi kullandık: IChatClient. Bu interface Microsoft.Extensions.AI.Abstractions paketinden gelir ve bir sohbet modelinden beklediğimiz iki şeyi tanımlar: tek seferde cevap almak (GetResponseAsync) ve cevabı akış hâlinde almak (GetStreamingResponseAsync). Mesajlar ChatMessage, ayarlar ChatOptions, cevap ChatResponse tipinde; token sayıları da sağlayıcı ne olursa olsun response.Usage altında gelir.
Sağlayıcıya özel kod bu interface'in arkasında kalır. OllamaSharp'taki OllamaApiClient bu interface'i doğrudan uygular. OpenAI SDK'sının istemcisini ise Microsoft.Extensions.AI.OpenAI paketindeki AsIChatClient() extension method'u IChatClient'a çevirir. Bunu bir priz adaptörü gibi düşün: cihazın (senin kodun) hep aynı fişi kullanır, duvardaki priz (sağlayıcı) değişince araya başka bir adaptör takarsın. Önceki yazılarda kullandığımız cache, function invocation gibi middleware'ler de bu interface üzerinden çalıştığı için sağlayıcıyla birlikte değişmez.
Ollama'nın OpenAI Uyumlu Kapısı: /v1
Ollama kendi API'sinin (/api/chat) yanında OpenAI'ın Chat Completions biçimini taklit eden bir endpoint de sunar: http://localhost:11434/v1/. OpenAI biçimini konuşan bir istemci, adresini buraya çevirdiğinde Ollama'yla konuşabilir:
curl -s http://localhost:11434/v1/chat/completions \
-H "Content-Type: application/json" \
-H "Authorization: Bearer ollama" \
-d '{"model":"gemma3:4b","temperature":0,"seed":42,"messages":[{"role":"user","content":"Name one classic book about refactoring. Title only."}]}' \
| python3 -m json.tool
{
"id": "chatcmpl-207",
"object": "chat.completion",
"created": 1793997041,
"model": "gemma3:4b",
"system_fingerprint": "fp_ollama",
"choices": [
{
"index": 0,
"message": {
"role": "assistant",
"content": "Refactoring: Improving the Design of Existing Code \n"
},
"finish_reason": "stop"
}
],
"usage": {
"prompt_tokens": 21,
"prompt_tokens_details": {
"cached_tokens": 0
},
"completion_tokens": 12,
"total_tokens": 33
}
}
Cevabın biçimi Ollama'nın kendi API'sinden farklı. Metin message.content yerine choices[0].message.content altında, token sayıları prompt_eval_count/eval_count yerine usage.prompt_tokens/usage.completion_tokens adıyla geliyor, bitiş sebebi finish_reason alanında. Authorization başlığındaki anahtar ise Ollama dokümanının deyişiyle "gerekli ama yok sayılır". OpenAI SDK'ları boş anahtarı kabul etmediği için oraya herhangi bir değer, genellikle ollama yazılır.
Sağlayıcıyı Yapılandırmadan Seçmek
Şimdi Bookstore için küçük bir minimal API yazalım. Hangi sağlayıcının kullanılacağına kod değil, yapılandırma karar versin. Önce appsettings.json içindeki varsayılan:
{
"Chat": {
"Provider": "Ollama",
"Endpoint": "http://localhost:11434",
"Model": "gemma3:4b"
}
}
Sonra Program.cs:
using System.ClientModel;
using Microsoft.Extensions.AI;
using OllamaSharp;
using OpenAI;
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
ChatSettings settings = builder.Configuration.GetSection("Chat").Get<ChatSettings>()
?? throw new InvalidOperationException("Missing 'Chat' section.");
// Uygulamanın geri kalanı yalnız IChatClient'ı tanır
builder.Services.AddChatClient(CreateClient(settings));
WebApplication app = builder.Build();
app.MapPost("/books/recommend", async (RecommendRequest request, IChatClient chat, CancellationToken ct) =>
{
List<ChatMessage> messages =
[
new(ChatRole.System, "You are a bookstore assistant. Recommend exactly one book in one sentence."),
new(ChatRole.User, request.Question),
];
ChatOptions options = new() { Temperature = 0, Seed = 42 };
ChatResponse response = await chat.GetResponseAsync(messages, options, ct);
return Results.Ok(new
{
provider = settings.Provider,
answer = response.Text,
inputTokens = response.Usage?.InputTokenCount,
outputTokens = response.Usage?.OutputTokenCount,
});
});
app.Run();
// Sağlayıcıya özel kodun tamamı burada
static IChatClient CreateClient(ChatSettings s) => s.Provider switch
{
"Ollama" => new OllamaApiClient(new Uri(s.Endpoint), s.Model),
"OpenAI" => new OpenAIClient(
new ApiKeyCredential(s.ApiKey ?? throw new InvalidOperationException("Missing 'Chat:ApiKey'.")),
new OpenAIClientOptions { Endpoint = new Uri(s.Endpoint) })
.GetChatClient(s.Model)
.AsIChatClient(),
_ => throw new InvalidOperationException($"Unknown provider '{s.Provider}'."),
};
public sealed class ChatSettings
{
public required string Provider { get; init; }
public required string Endpoint { get; init; }
public required string Model { get; init; }
public string? ApiKey { get; init; }
}
public sealed record RecommendRequest(string Question);
Endpoint'in kendisi sağlayıcıyı bilmiyor. Parametre olarak IChatClient istiyor ve onu DI container'dan alıyor; .NET Core Web API serisinde controller'lara service enjekte ederken yaptığımızın aynısı. AddChatClient client'ı container'a kaydeder ve bir ChatClientBuilder döner; cache ya da logging gibi middleware'leri zincire bu builder üzerinden eklersin. Sağlayıcıya özel kodun tamamı CreateClient içinde: hangi sağlayıcı seçilirse seçilsin dışarıya çıkan tip aynı.
Aynı Kod, İki Sağlayıcı
Uygulamayı varsayılan ayarla çalıştır ve ikinci bir terminalden istek at:
dotnet run
# ikinci terminalde
curl -s localhost:5238/books/recommend -H "Content-Type: application/json" \
-d '{"question":"I liked Clean Code. What should I read next?"}'
{"provider":"Ollama","answer":"If you enjoyed “Clean Code,” you might find “The Pragmatic Programmer” a fantastic next read to deepen your understanding of software development principles and best practices.","inputTokens":41,"outputTokens":33}
Şimdi sağlayıcıyı değiştir. Koda dokunmuyoruz; anahtarı user secrets'a koyuyor, sağlayıcıyı ve adresi ortam değişkeniyle eziyoruz. ASP.NET Core'da iç içe ayarlar ortam değişkeninde çift alt çizgiyle yazılır: Chat:Provider ayarı Chat__Provider olur.
dotnet user-secrets init
dotnet user-secrets set "Chat:ApiKey" "ollama"
dotnet user-secrets list
Chat:ApiKey = ollama
Chat__Provider=OpenAI Chat__Endpoint=http://localhost:11434/v1 dotnet run
# ikinci terminalde, aynı istek
curl -s localhost:5238/books/recommend -H "Content-Type: application/json" \
-d '{"question":"I liked Clean Code. What should I read next?"}'
{"provider":"OpenAI","answer":"If you enjoyed “Clean Code,” you might find “The Pragmatic Programmer” a fantastic next read to deepen your understanding of software development principles and best practices.","inputTokens":41,"outputTokens":33}
Bu sefer istek OpenAI'ın resmî .NET SDK'sından geçti ve Ollama'nın /v1 endpoint'ine gitti. Arkada aynı model, aynı temperature ve aynı seed olduğu için cevap ve token sayıları birebir aynı çıktı. Açılıştaki iki sorunun cevabı bu: Ollama'yı OpenAI SDK'sıyla çağırabiliyorsun ve sağlayıcı değiştirmek kod değişikliği istemiyor. Gerçek bir bulut sağlayıcısına geçerken değişen şey Endpoint, Model ve gerçek bir ApiKey olur.
API Anahtarlarını Nerede Saklamalı?
Bulut sağlayıcısının API anahtarı bir şifredir; sızarsa senin hesabına fatura yazılır. Kural basit: anahtar koda, appsettings.json'a ve repo'ya girmez. Web Güvenliği serisindeki OWASP yazılarında konuştuğumuz "sırları kaynak kodda tutma" kuralının LLM tarafındaki karşılığı bu.
Geliştirme makinesinde .NET'in user secrets aracı işini görür. dotnet user-secrets ayarı proje klasörünün dışında, kullanıcı profilindeki bir JSON dosyasında tutar; dosya repo'ya girmez ve uygulama Development ortamında çalışırken yapılandırmaya otomatik eklenir. Ama şifrelenmez, yalnız geliştirme için tasarlanmıştır. Sunucuda ya da container'da anahtar ortam değişkeniyle ya da Azure Key Vault gibi bir sır yönetim servisiyle gelir. ASP.NET Core yapılandırma kaynaklarını sırayla okur ve sonra gelen öncekini ezer: appsettings.json, ortama özel appsettings.{Environment}.json, Development'ta user secrets, ortam değişkenleri, en son komut satırı argümanları. Yukarıdaki örnekte ortam değişkeni bu yüzden appsettings.json'daki Ollama değerini ezdi.
Bir de anahtar eksikse ne olacağına karar ver. Örnekteki CreateClient anahtar yoksa uygulama açılırken Missing 'Chat:ApiKey'. hatasıyla düşer. Bu, ilk kullanıcı isteğinde 401 almaktan iyidir: yanlış yapılandırılmış bir uygulama deploy anında fark edilir.
TypeScript'te Aynı Fikir
TypeScript tarafında Microsoft.Extensions.AI gibi hazır bir ortak interface yok, ama ihtiyacın olan kadarını kendin tanımlayabilirsin. Uygulama yalnız ChatClient interface'ini tanısın, iki implementasyon da arkasında kalsın:
import { Ollama } from 'ollama';
import OpenAI from 'openai';
type Message = { role: 'system' | 'user' | 'assistant'; content: string };
// Uygulamanın tanıdığı tek sözleşme
interface ChatClient {
complete(messages: Message[]): Promise<string>;
}
function createOllamaClient(host: string, model: string): ChatClient {
const client = new Ollama({ host });
return {
async complete(messages) {
const res = await client.chat({ model, messages, options: { temperature: 0, seed: 42 } });
return res.message.content;
},
};
}
function createOpenAIClient(baseURL: string, apiKey: string, model: string): ChatClient {
const client = new OpenAI({ baseURL, apiKey });
return {
async complete(messages) {
const res = await client.chat.completions.create({ model, messages, temperature: 0, seed: 42 });
return res.choices[0].message.content ?? '';
},
};
}
function createClient(): ChatClient {
const provider = process.env.LLM_PROVIDER ?? 'ollama';
const model = process.env.LLM_MODEL ?? 'gemma3:4b';
switch (provider) {
case 'ollama':
return createOllamaClient(process.env.OLLAMA_HOST ?? 'http://localhost:11434', model);
case 'openai': {
const apiKey = process.env.LLM_API_KEY;
if (!apiKey) throw new Error('LLM_API_KEY is not set.');
return createOpenAIClient(process.env.LLM_BASE_URL ?? 'http://localhost:11434/v1', apiKey, model);
}
default:
throw new Error(`Unknown provider '${provider}'.`);
}
}
const chat = createClient();
const answer = await chat.complete([
{ role: 'system', content: 'You are a bookstore assistant. Recommend exactly one book in one sentence.' },
{ role: 'user', content: 'I liked Clean Code. What should I read next?' },
]);
console.log(`[${process.env.LLM_PROVIDER ?? 'ollama'}] ${answer}`);
npx tsx chat.ts
[ollama] If you enjoyed “Clean Code,” you might find “The Pragmatic Programmer” a fantastic next read to deepen your understanding of software development principles and best practices.
LLM_PROVIDER=openai LLM_API_KEY=ollama npx tsx chat.ts
[openai] If you enjoyed “Clean Code,” you might find “The Pragmatic Programmer” a fantastic next read to deepen your understanding of software development principles and best practices.
LLM_PROVIDER=openai npx tsx chat.ts
Error: LLM_API_KEY is not set.
C# tarafıyla aynı cevap; Ollama'nın kendi istemcisi ile openai paketi aynı modele gitti. Interface'i bilerek küçük tuttum: uygulamanın kullanmadığı bir yeteneği soyutlamaya koymak, onu her sağlayıcı için ayrı ayrı yazmak demek. Streaming ya da structured output gerektiğinde interface'e o method'u eklersin.
Soyutlamanın Sınırları
Ortak interface ortak yetenekleri kapsar; her sağlayıcının kendine özgü bir kısmı dışarıda kalır. Bunu bilmeden geçiş yaparsan kod derlenir ama davranış sessizce değişir.
İlk örnek num_ctx. Context window yazısında gördüğümüz gibi Ollama'da bağlam penceresini istek başına options.num_ctx ile büyütebiliyorsun. OpenAI uyumlu /v1 endpoint'inde bu ayarın karşılığı yok; Ollama dokümanı bunun için Modelfile'da PARAMETER num_ctx ile yeni bir model oluşturmayı öneriyor. Yani /v1'e geçen bir uygulama, uzun prompt'larda varsayılan 4096 token'a geri düşebilir ve hata almaz.
İkinci örnek model adları. Model adı yapılandırmada durur ama her sağlayıcının kataloğu farklıdır. Kurulu olmayan bir model adı verdiğinde openai paketi şu hatayı fırlattı:
LLM_PROVIDER=openai LLM_API_KEY=ollama LLM_MODEL=gemma3:1b npx tsx chat.ts
NotFoundError: 404 model 'gemma3:1b' not found
Üçüncüsü sağlayıcıya özel parametreler: Ollama'nın think ve keep_alive ayarları, sağlayıcıların farklı structured output ve tool calling ayrıntıları. ChatOptions ortak olanları (temperature, top-p, seed, max tokens, stop sequence'ler, tools) kapsar; geri kalanı için AdditionalProperties ve RawRepresentationFactory gibi kaçış kapıları var. Bunları kullandığın an kodun o sağlayıcıya biraz bağlanır; bunu bilinçli yap ve CreateClient gibi tek bir yerde topla.
Sık Düşülen Tuzaklar
Birincisi, API anahtarını appsettings.json'a "geçici olarak" yazmak. Geçici anahtar commit'lenir, commit push edilir ve geçmişte kalır. Anahtar bir kez repo'ya girdiyse silmek yetmez; sağlayıcıdan iptal edip yenisini üret.
İkincisi, istemciyi her istekte yeniden oluşturmak. new OllamaApiClient(...) ya da new OpenAIClient(...) çağrısını endpoint'in içine yazmak yerine bir kez oluştur ve DI ile paylaş; HTTP bağlantıları ve middleware zinciri de böylece tek yerde yönetilir.
Üçüncüsü, "OpenAI uyumlu" ifadesini "her şeyiyle aynı" sanmak. Uyumluluk belgede listelenen alanlar içindir. Ollama'nın /v1 endpoint'i örneğin logprobs desteklemiyor ve num_ctx ayarını almıyor. Geçmeden önce kullandığın her alanı hedef sağlayıcının dokümanında kontrol et.
Dördüncüsü, sağlayıcıyı değiştirip prompt'u ve değerlendirmeyi aynı bırakmak. Kod aynı kalır ama model değişir; aynı prompt başka bir modelde farklı uzunlukta, farklı biçimde cevap üretebilir. 15. yazıdaki golden set'i her geçişte yeniden çalıştır.
Küçük Bir Deneme
★☆☆ Minimal API'yi bu sefer Chat__Model=qwen3:4b ortam değişkeniyle çalıştır. Kodda hiçbir şey değiştirmeden model değişti mi? Kısa bir cevaba rağmen outputTokens neden bu kadar büyük? 9. yazıdaki thinking konusunu hatırla.
★★☆ 16. yazıdaki UseDistributedCache middleware'ini AddChatClient'ın döndürdüğü builder'a ekle; container'da bir IDistributedCache olması için önce AddDistributedMemoryCache() çağırman gerekecek. Aynı isteği iki kez at. İkinci cevap hiç token harcamadığı hâlde inputTokens ve outputTokens ne gösteriyor? Maliyet raporu yazarken bu neden önemli?
★★★ TypeScript'teki ChatClient interface'ine stream(messages) method'unu ekle ve iki implementasyon için yaz: Ollama tarafında stream: true ile gelen async iterator'ı, OpenAI tarafında stream: true ile gelen akışı aynı AsyncIterable<string> tipine çevir.
Böylece Blok A'nın sonuna geldik: token'dan context window'a, sampling'den tool calling'e, embedding'den RAG'e kadar kemik yapıyı yerel modellerle gerçekten çalıştırarak kurduk. Bir sonraki yazıyla seri tarihçeye dönüyor: 2017'deki "Attention Is All You Need" makalesinden ChatGPT'nin çıkışına kadar bugünkü LLM'leri mümkün kılan adımlara bakacağız.