TEKNİK YAZI / API Tasarımı ve Entegrasyonlar

LLM API Gateway: Akış, Kullanım Ölçümü ve Faturalama

LLM API'sini kendi backend'inizden geçirirken SSE akışını bozmadan aktarmak, token ve cache kullanımını doğru ölçmek ve yarım kalan akışları faturalamak.

LLM API gateway, uygulamalarınızla model sağlayıcısı arasında duran ve her isteği kendi kurallarınızdan geçiren ara katmandır. Sağlayıcı anahtarını tek yerde tutar, hangi müşterinin ya da iç ekibin (bu yazıda kiracı, İngilizcede tenant) hangi modeli ne kadar kullanabileceğine karar verir, yanıtı istemciye akış hâlinde iletir ve her isteğin maliyetini kayda geçirir. Kâğıt üzerinde basit bir ters proxy gibi görünür; zorluk, akışlı yanıtın ortasında gelen kullanım verisini doğru okumak ve yarıda kalan istekleri doğru faturalamaktır.

Bu yazı Anthropic Messages API üzerinden ilerliyor ve OpenAI’nin farklı davrandığı noktaları ayrıca gösteriyor. Örnek kodlar Node.js 20 ve sonrası için bağımlılıksız yazıldı; parçalanmış akış, kopan istemci, akış ortasında hata ve eşzamanlı bütçe rezervasyonu senaryolarıyla yerelde test edildi. Fiyat ve davranış bilgileri 10 Ekim 2026 tarihli sağlayıcı belgelerine dayanıyor.

Kısaca

Gateway ne işe yarar?

Uygulamalar sağlayıcıya doğrudan bağlandığında anahtar her serviste çoğalır, maliyet hangi ürüne ya da müşteriye ait bilinmez ve model değişikliği her ekipte ayrı ayrı yapılır. Gateway bu sorumlulukları tek noktada toplar:

Gateway’in yapmaması gereken işler de aynı derecede önemlidir. Akıştaki olayları yeniden yazmamalı, tanımadığı olayları atmamalı ve istem (prompt) içeriğini varsayılan olarak saklamamalıdır. Bu üçü, sağlayıcı yeni bir özellik eklediğinde sessiz kırılmalara ve kişisel veri sorumluluğuna yol açar.

Bir isteğin yaşam döngüsü

Akışlı bir isteğin gateway içindeki yolu sekiz adımdan oluşur:

  1. Kimlik doğrulama: Kiracı anahtarı doğrulanır, istek gövdesi boyut sınırıyla okunur.
  2. Politika: Model izni, max_tokens tavanı ve izin verilen başlıklar uygulanır.
  3. Rezervasyon: İsteğin en kötü durumdaki maliyeti hesaplanır ve kiracının bütçesinden ayrılır. Bütçe yetmiyorsa istek burada reddedilir.
  4. Upstream isteği: İstek, gateway’in sağlayıcı anahtarıyla ve yalnız izin verilen başlıklarla sağlayıcıya gönderilir.
  5. Aktarım: Sağlayıcıdan gelen baytlar değiştirilmeden istemciye yazılır.
  6. Ölçüm: Aynı baytların bir kopyası ayrıştırılır ve kullanım bilgisi toplanır.
  7. Sonuçlandırma: Akış tamamlandıysa kesin maliyet yazılır ve rezervasyon kapatılır. Tamamlanmadıysa kayıt “kesinleşmedi” olarak işaretlenir.
  8. Mutabakat: Gateway kayıtları düzenli olarak sağlayıcının kullanım ve maliyet raporlarıyla karşılaştırılır.
İstemci, gateway, PostgreSQL ledger ve sağlayıcı arasındaki sekiz adımlık istek akışını gösteren sıra diyagramı
Şekil 1. Bir isteğin gateway içindeki yolu: rezervasyon akıştan önce, kesinleştirme akıştan sonra, mutabakat günlük.

Rezervasyon ve sonuçlandırma iki ayrı, kısa veritabanı işlemidir. Akış dakikalarca sürebilir; bu süre boyunca açık bir veritabanı transaction’ı ve satır kilidi tutmak aynı kiracının diğer isteklerini bekletir.

Akışı bozmadan aktarmak

Gateway’in istemciye yazdığı her bayt, sağlayıcının gönderdiği baytla aynı olmalıdır. Olayları ayrıştırıp yeniden serileştirmek üç sorun çıkarır: alan sırası ve boşluklar değişir, ara sıra gelen ping olayları kaybolabilir ve sağlayıcının sonradan eklediği olay türleri istemciye ulaşmaz. Anthropic, sürümleme politikası gereği yeni olay türleri eklenebileceğini ve istemcilerin tanımadıkları olayları sorunsuz geçmesi gerektiğini belgesinde açıkça yazar. Doğru yaklaşım, baytları olduğu gibi aktarmak ve ölçüm için yalnız bir kopyayı ayrıştırmaktır.

İstek başlıkları: Sağlayıcıya giden başlıklar izin listesiyle kurulur. İstemcinin Authorization başlığı, çerezleri ve rastgele başlıkları upstream’e taşınmaz; beta özellikleri açan anthropic-beta değerleri yalnız onaylanmışsa geçer. RFC 9110, Connection, Keep-Alive, TE, Transfer-Encoding ve Upgrade gibi bağlantıya özgü (hop-by-hop) alanların ara katmanda kaldırılması ya da değiştirilmesi gerektiğini söyler.

Yanıt başlıkları: Sağlayıcı yanıtındaki anthropic-ratelimit-* ve anthropic-organization-id başlıkları sağlayıcı hesabınıza aittir. Bunları kiracıya aktarmak, kuruluşunuzun toplam kapasitesini ve hesap kimliğini dışarı açar. Kiracıya gateway’in kendi istek kimliğini ve kendi limit bilgisini dönün; sağlayıcının request-id değerini ise destek talepleri için kendi kaydınızda saklayın.

Tamponlama ve sıkıştırma: Yanıt text/event-stream olarak, Cache-Control: no-cache ve nginx için X-Accel-Buffering: no başlıklarıyla gönderilir. Akış rotası sıkıştırma ara katmanından muaf tutulmalıdır. Bu ayarların ayrıntısı Server-Sent Events yazısındaki proxy ve tamponlama bölümünde anlatılıyor.

Zaman aşımı: Akışlı istekte toplam süre sınırı yanlış ölçüdür; uzun bir yanıt meşru olarak dakikalar sürebilir. Bunun yerine iki parça arasında geçen süre sınırlanır. Anthropic akışına ping olayları girebilir, ancak sıklıkları belgelenmemiştir ve model çalışırken olaylar arasında gecikme olabileceği de belirtilir. Bu yüzden boşta kalma süresi cömert seçilmeli ve gerçek trafikte görülen en uzun sessizlik ölçülerek ayarlanmalıdır. Önündeki nginx’in proxy_read_timeout değeri de bu süreden uzun olmalıdır.

Geri basınç: İstemci yavaş okuyorsa res.write() çağrısı false döner. Gateway bu durumda drain olayını beklemeden yazmaya devam ederse yanıt bellekte birikir.

SSE akışını ölçüm için ayrıştırmak

Ağdan gelen parçalar olay sınırlarıyla hizalı değildir. Bir parça olayın ortasında, satırın ortasında hatta bir karakterin ortasında bitebilir. WHATWG spesifikasyonu ayrıştırıcının uyması gereken kuralları tanımlar:

UTF-8’de ğ, ş ve İ gibi karakterler iki bayt tutar. Parça sınırı bu iki baytın arasına düşerse her parçayı ayrı ayrı çözen kod bozuk karakter üretir. TextDecoder ile { stream: true } kullanmak tamamlanmamış baytı bir sonraki parçaya kadar bekletir. Aynı sorun \r\n çiftinin iki parçaya bölünmesinde de vardır.

// sse-parser.js: Bayt parçalarını alır, tamamlanan SSE olaylarını döndürür.
// Akışı değiştirmez; gateway aynı baytları istemciye ayrıca aktarır.
export class SseParser {
  #decoder = new TextDecoder("utf-8"); // baştaki BOM'u kendisi atar
  #buffer = "";
  #eventType = "";
  #dataLines = [];

  push(chunk) {
    // stream: true, parça sınırında bölünen çok baytlı karakteri (ğ, ş, İ) bekletir
    this.#buffer += this.#decoder.decode(chunk, { stream: true });
    return this.#drain(false);
  }

  end() {
    this.#buffer += this.#decoder.decode();
    const events = this.#drain(true);
    // Spesifikasyon: boş satırla bitmeyen son olay teslim edilmez
    this.#eventType = "";
    this.#dataLines = [];
    return events;
  }

  #drain(final) {
    const events = [];
    let index;
    while ((index = this.#buffer.search(/[\r\n]/)) !== -1) {
      // "\r\n" iki parçaya bölünmüş olabilir: tampon "\r" ile bitiyorsa bekle
      const lastIsCr = index === this.#buffer.length - 1;
      if (this.#buffer[index] === "\r" && lastIsCr && !final) break;
      const width = this.#buffer.startsWith("\r\n", index) ? 2 : 1;
      const line = this.#buffer.slice(0, index);
      this.#buffer = this.#buffer.slice(index + width);
      const event = this.#line(line);
      if (event) events.push(event);
    }
    return events;
  }

  #line(line) {
    if (line === "") {
      // Boş satır olayı bitirir; data satırı yoksa teslim edilecek olay da yoktur
      const event = this.#dataLines.length
        ? {
            type: this.#eventType || "message",
            data: this.#dataLines.join("\n"),
          }
        : null;
      this.#eventType = "";
      this.#dataLines = [];
      return event;
    }
    if (line.startsWith(":")) return null; // yorum satırı (heartbeat)

    const colon = line.indexOf(":");
    const field = colon === -1 ? line : line.slice(0, colon);
    let value = colon === -1 ? "" : line.slice(colon + 1);
    if (value.startsWith(" ")) value = value.slice(1);

    if (field === "event") this.#eventType = value;
    else if (field === "data") this.#dataLines.push(value);
    // id ve retry alanları kullanım ölçümü için gerekmiyor
    return null;
  }
}

Anthropic akışında kullanım bilgisi

Messages API akışı sabit bir sırayla ilerler: message_start, her içerik bloğu için content_block_start, çoğunlukla bir veya daha fazla content_block_delta ve content_block_stop, ardından bir veya daha fazla message_delta ve son olarak message_stop. Araya herhangi sayıda ping olayı girebilir. Web araması sonucu gibi bazı bloklar ise yalnız başlangıç ve bitiş olayıyla gelir.

Kullanım bilgisi iki yerde gelir:

Kümülatif sayıları toplamak en sık yapılan hatadır; iki message_delta olayı gelen bir akışta çıktı iki kez sayılır. Doğrusu, her message_delta olayındaki alanları önceki değerlerin üzerine yazmaktır. Bu kural yalnız çıktı için de geçerli değildir. Anthropic’in web araması örneğinde message_start içinde 2.679 olan input_tokens, sunucu tarafı aracın getirdiği sonuçlarla birlikte son message_delta içinde 10.682 olarak gelir ve aynı olay server_tool_use.web_search_requests alanını taşır. message_start değerini kesin girdi sayısı kabul eden kod bu isteği eksik faturalar.

message_start ve message_delta olaylarındaki kullanım sayıları ile bunları eksik sayan, çift sayan ve doğru okuyan üç kod yaklaşımının karşılaştırması
Şekil 2. Anthropic'in web araması örneği: aynı akışı üç farklı şekilde okuyan kodun bulduğu sonuçlar.

Akış 200 OK ile başladıktan sonra da hata gelebilir. Yoğun dönemlerde sağlayıcı akışın içinde error olayı gönderir; örneğin overloaded_error, akışsız bir istekte HTTP 529’a karşılık gelir. Bu nedenle HTTP durum kodu tek başına başarı ölçüsü değildir: isteğin tamamlandığını yalnız message_stop olayı gösterir.

// anthropic-usage.js: Messages API akışından kullanım ve sonuç bilgisini toplar.
export function createUsageTracker() {
  const state = {
    model: null,
    usage: null, // sağlayıcının usage nesnesi, alan adları değiştirilmeden
    stopReason: null,
    completed: false, // message_stop görüldü mü
    error: null, // akış içinde gelen error olayı
  };

  function onEvent(event) {
    let payload;
    try {
      payload = JSON.parse(event.data);
    } catch {
      return; // ölçümü ilgilendirmeyen, JSON olmayan olay
    }

    switch (payload.type) {
      case "message_start":
        state.model = payload.message.model;
        state.usage = { ...payload.message.usage };
        break;
      case "message_delta":
        // message_delta içindeki sayılar kümülatiftir: toplanmaz, üzerine yazılır
        for (const [key, value] of Object.entries(payload.usage ?? {})) {
          if (value != null) state.usage = { ...state.usage, [key]: value };
        }
        state.stopReason = payload.delta?.stop_reason ?? state.stopReason;
        break;
      case "message_stop":
        state.completed = true;
        break;
      case "error":
        state.error = payload.error;
        break;
      default:
        // ping, içerik olayları ve ileride eklenecek yeni olay türleri
        break;
    }
  }

  return { state, onEvent };
}

OpenAI’de kullanım bilgisi farklı gelir

Birden fazla sağlayıcıyı aynı gateway’den geçirecekseniz kullanım alanlarının aynı adı taşıyıp farklı anlam taşıdığı noktalara dikkat etmek gerekir.

Chat Completions: Akışlı istekte kullanım bilgisi varsayılan olarak gelmez. stream_options: { include_usage: true } verildiğinde data: [DONE] satırından önce ek bir parça gönderilir; bu parçanın usage alanı isteğin tamamının kullanımını taşır ve choices dizisi her zaman boştur. Diğer parçalarda usage alanı null değerindedir. OpenAI’nin API başvuru belgesi, akış kesilirse bu son parçanın hiç gelmeyebileceğini ayrıca not eder. choices[0] değerini kontrol etmeden okuyan kod da bu son parçada hata verir.

Responses API: Akış response.completed olayıyla biter ve kullanım bilgisi bu olaydaki response.usage nesnesinde gelir. Başarısız ya da yarım kalan yanıtlar için response.failed ve response.incomplete, akış sırasında oluşan hatalar için error olayı vardır; ölçüm kodu bu dört sonlanma yolunu da ele almalıdır.

En önemli fark girdi sayısının tanımıdır. Anthropic’te input_tokens yalnız son önbellek kırılma noktasından sonraki token’ları gösterir; önbellekten okunan ve önbelleğe yazılan token’lar ayrı alanlarda gelir ve toplam girdi üçünün toplamıdır. OpenAI’nin prompt caching belgesindeki maliyet örneği ise önbelleksiz girdiyi input_tokens değerinden cached_tokens ve cache_write_tokens çıkarılarak hesaplar; yani önbellek sayıları input_tokens içinde yer alır.

Aynı 4.096 token'lık girdinin Anthropic'te üç ayrı alana bölündüğünü, OpenAI'de ise önbellek sayılarının input_tokens içinde yer aldığını gösteren çubuk diyagram
Şekil 3. Aynı girdi, iki sağlayıcıda iki farklı sayım.
KullanımAnthropic MessagesOpenAI Responses
Önbellekten okunan girdicache_read_input_tokens (ayrı alan)input_tokens_details.cached_tokens (toplamın içinde)
Önbelleğe yazılan girdicache_creation_input_tokens (ayrı alan)input_tokens_details.cache_write_tokens (toplamın içinde)
Önbelleksiz girdiinput_tokensinput_tokens eksi iki önbellek alanı
Toplam girdiÜç alanın toplamıinput_tokens

İki sağlayıcının input_tokens alanını aynı sütuna yazan gateway, Anthropic isteklerinde girdiyi eksik, OpenAI isteklerinde önbellek indirimini yanlış hesaplar. OpenTelemetry’nin üretken yapay zekâ semantik kuralları da (henüz Development aşamasında) Anthropic için gen_ai.usage.input_tokens değerinin input_tokens, önbellekten okunan ve önbelleğe yazılan token’ların toplamı olarak hesaplanmasını şart koşar. Güvenli yol, sağlayıcının usage nesnesini hiç değiştirmeden saklamak ve normalleştirilmiş sütunları bunun yanında, sağlayıcıya özgü kurallarla üretmektir.

Claude API maliyeti nasıl hesaplanır?

Bir isteğin maliyeti “token sayısı çarpı fiyat” değildir. 10 Ekim 2026 itibarıyla Anthropic fiyatlandırmasında aynı istek içinde birden fazla fiyat sınıfı bulunur:

Bu yüzden fiyatlar kodun içine sabit olarak gömülmez, yürürlük tarihi olan sürümlü veri olarak tutulur ve her maliyet kaydına hangi fiyat sürümüyle hesaplandığı yazılır. Para birimi kesirli sayıyla tutulmaz; aşağıdaki örnek token başına nano-USD (1 USD = 10⁹) kullanır, böylece $0,10 / MTok gibi fiyatlar da tam sayı olarak ifade edilir.

Anthropic’in prompt caching belgesindeki örnek kullanımı Claude Sonnet 5.5 fiyatlarıyla hesaplayalım. Fiyatlar sık değişir; tablo 10 Ekim 2026 tarihlidir ve kendi hesabınızda güncel fiyat sayfası esas alınmalıdır.

SınıfTokenFiyat (USD / MTok)Maliyet (USD)
Önbelleksiz girdi2.0482,000,004096
Önbellekten okuma1.8000,100,000180
Önbelleğe yazma (5 dakika)1482,500,000370
Önbelleğe yazma (1 saat)1004,000,000400
Çıktı50310,000,005030
Toplam0,010076

Önbellek okumasını taban girdi fiyatıyla, önbellek yazımını da indirimsiz girdi fiyatıyla hesaplayan bir gateway bu istekte farklı bir tutar bulur ve hata trafikle birlikte büyür.

// cost.js: Fiyatlar kodda sabit değil, sürümlü veri olarak tutulur.
// Birim: token başına nano-USD (1 USD = 1e9). $2 / MTok = 2000 nano-USD / token.
export const PRICE_TABLE = {
  version: "anthropic-2026-10-10",
  models: {
    "claude-sonnet-5-5": {
      input: 2000,
      cacheWrite5m: 2500,
      cacheWrite1h: 4000,
      cacheRead: 100,
      output: 10000,
      webSearch: 10_000_000, // arama başına $0,01 ($10 / 1.000 arama)
    },
  },
};

export class UsageIncompleteError extends Error {}

export function costNanoUsd(usage, price) {
  // Zorunlu alanlar yoksa maliyet bilinmiyordur; 0 kabul edilmez
  if (
    !Number.isInteger(usage?.input_tokens) ||
    !Number.isInteger(usage?.output_tokens)
  ) {
    throw new UsageIncompleteError("usage eksik; kayıt mutabakata bırakılmalı");
  }

  // Cache alanları önbellek kullanılmayan isteklerde 0 gelir
  const cacheRead = usage.cache_read_input_tokens ?? 0;
  const cacheWrite = usage.cache_creation_input_tokens ?? 0;
  const write1h = usage.cache_creation?.ephemeral_1h_input_tokens ?? 0;
  const write5m =
    usage.cache_creation?.ephemeral_5m_input_tokens ?? cacheWrite - write1h;
  const searches = usage.server_tool_use?.web_search_requests ?? 0;

  return (
    usage.input_tokens * price.input +
    write5m * price.cacheWrite5m +
    write1h * price.cacheWrite1h +
    cacheRead * price.cacheRead +
    usage.output_tokens * price.output +
    searches * price.webSearch
  );
}

Örnek fonksiyon Haiku 5.5’in uzun istem kademesini ve inference_geo çarpanını uygulamaz. Bu modelleri ya da bölge seçimini kullanıyorsanız fiyat seçimi isteğin toplam girdi uzunluğuna ve istek parametrelerine bakmalıdır.

Bütçe: önce rezervasyon, sonra kesinleştirme

Harcamayı istek bittikten sonra kaydetmek, bütçe sınırının ancak aşıldıktan sonra fark edilmesi demektir. Ödeme sistemlerindeki provizyon mantığı burada da işe yarar: istekten önce en kötü durumdaki tutar bloke edilir, istek bitince gerçek tutar kesinleşir ve fark serbest kalır.

En kötü durum, tüm girdinin en pahalı girdi sınıfından (1 saatlik önbelleğe yazma) ve çıktının max_tokens sınırına kadar faturalanmasıdır. Girdi sayısı için Anthropic’in token sayma uç noktası kullanılabilir. Bu uç nokta ücretsizdir, mesaj oluşturmadan bağımsız bir istek sınırına tabidir ve döndürdüğü sayı bir tahmindir. Web araması gibi sunucu araçları içeren istekleri ise kabul etmez; bu durumda isteği bütçe kontrolü olmadan geçirmek yerine reddetmek ya da kendi muhafazakâr tahmininizi kullanmak gerekir. Sunucu araçlarının getirdiği sonuçlar da girdiye eklendiği için bu araçlar açıkken rezervasyon kesin bir üst sınır değildir; araç kullanım sayısını ayrıca sınırlayın.

Rezervasyon tek bir SQL ifadesiyle yapılır: bütçe kontrolü ve bloke etme aynı UPDATE içinde olduğu için aynı anda gelen istekler sınırı aşamaz. Aşağıdaki tablo ve sorgular PostgreSQL 15’te, aynı kiracıya eşzamanlı 30 rezervasyon gönderilerek denendi; 100 birimlik bütçeye 10’ar birimlik isteklerden tam 10 tanesi geçti.

CREATE TABLE tenant_budget (
  tenant_id     text PRIMARY KEY,
  limit_nanousd bigint NOT NULL CHECK (limit_nanousd >= 0),
  spent_nanousd bigint NOT NULL DEFAULT 0 CHECK (spent_nanousd >= 0), -- kesinleşmiş maliyet
  held_nanousd  bigint NOT NULL DEFAULT 0 CHECK (held_nanousd >= 0)   -- açık rezervasyonlar
);

CREATE TABLE llm_usage_ledger (
  request_id          uuid PRIMARY KEY,               -- gateway'in kendi istek kimliği
  tenant_id           text NOT NULL REFERENCES tenant_budget (tenant_id),
  model               text NOT NULL,
  status              text NOT NULL
    CHECK (status IN ('reserved', 'settled', 'unreconciled', 'released')),
  reserved_nanousd    bigint NOT NULL,
  cost_nanousd        bigint,  -- kesinleşene kadar NULL; bilinmeyen maliyet 0 yazılmaz
  lower_bound_nanousd bigint,  -- yarım akışta bilinen alt sınır
  usage_raw           jsonb,   -- sağlayıcının usage nesnesi, değiştirilmeden
  price_version       text,
  upstream_request_id text,
  reason              text,
  created_at          timestamptz NOT NULL DEFAULT now(),
  finalized_at        timestamptz
);

CREATE INDEX llm_usage_ledger_tenant_created ON llm_usage_ledger (tenant_id, created_at);
CREATE INDEX llm_usage_ledger_open ON llm_usage_ledger (created_at)
  WHERE status IN ('reserved', 'unreconciled');
-- Rezervasyon: $1 request_id, $2 tenant_id, $3 model, $4 tutar.
-- Etkilenen satır yoksa bütçe yetersizdir.
WITH hold AS (
  UPDATE tenant_budget
     SET held_nanousd = held_nanousd + $4
   WHERE tenant_id = $2
     AND spent_nanousd + held_nanousd + $4 <= limit_nanousd
  RETURNING tenant_id
)
INSERT INTO llm_usage_ledger (request_id, tenant_id, model, status, reserved_nanousd)
SELECT $1, tenant_id, $3, 'reserved', $4 FROM hold;

-- Kesinleştirme: $1 request_id, $2 maliyet, $3 usage, $4 fiyat sürümü, $5 upstream request-id.
-- Yalnız açık kayıtları günceller; aynı isteği ikinci kez kesinleştirmek etkisizdir.
WITH done AS (
  UPDATE llm_usage_ledger
     SET status = 'settled', cost_nanousd = $2, usage_raw = $3,
         price_version = $4, upstream_request_id = $5, finalized_at = now()
   WHERE request_id = $1 AND status IN ('reserved', 'unreconciled')
  RETURNING tenant_id, reserved_nanousd, cost_nanousd
)
UPDATE tenant_budget AS b
   SET held_nanousd  = b.held_nanousd - done.reserved_nanousd,
       spent_nanousd = b.spent_nanousd + done.cost_nanousd
  FROM done
 WHERE b.tenant_id = done.tenant_id;
reserved durumundan settled, unreconciled ve released durumlarına geçişleri ve her durumun bütçedeki held ve spent alanlarına etkisini gösteren durum diyagramı
Şekil 4. Ledger kayıt durumları. Kesinleşmemiş kayıt, mutabakat kesin maliyeti bulana kadar bütçeyi bloke etmeye devam eder.

Kayıtların dört durumu vardır:

Son durumdaki kayda 0 yazmak en tehlikeli kısayoldur: teknik olarak üretilmiş ve sağlayıcının faturalayacağı bir isteği maliyetsiz gösterir ve kiracının bütçesini olduğundan geniş gösterir.

Gateway iskeleti

Aşağıdaki kod önceki parçaları bir araya getirir. authenticate, countTokens ve ledger dışarıdan verilir: ilki kiracı anahtarını doğrular, ikincisi token sayma uç noktasını çağırır, üçüncüsü yukarıdaki SQL’i çalıştırır (reserve, rezervasyon sorgusu bir satır eklediyse true döner). Örnek yalnız akışlı istekleri kabul eder; akışsız yanıtta kullanım bilgisi tek bir JSON gövdesinde gelir.

Kodun kalbi akış döngüsüdür: her parça önce istemciye yazılır, sonra aynı parça ayrıştırıcıya verilir; akış bitince kayıt sonuçlandırılır. Kimlik, politika, rezervasyon, yeniden deneme ve hata eşleme dahil tam dosya hemen altındaki açılır blokta.

// gateway.js içinden: akış döngüsü ve sonuçlandırma
res.writeHead(200, {
  "content-type": "text/event-stream",
  "cache-control": "no-cache",
  "x-accel-buffering": "no", // nginx bu yanıtı tamponlamasın
});

const parser = new SseParser();
const tracker = createUsageTracker();
let transportError = null;
let idleTimedOut = false;
let idleTimer;
const armIdleTimer = () => {
  clearTimeout(idleTimer);
  idleTimer = setTimeout(() => {
    idleTimedOut = true; // ping dahil hiçbir bayt gelmedi
    upstreamAbort.abort();
  }, idleTimeoutMs);
};

try {
  armIdleTimer();
  for await (const chunk of upstream.body) {
    armIdleTimer();
    // Baytlar olduğu gibi aktarılır; ayrıştırma yalnız ölçüm içindir
    if (!res.write(chunk)) {
      await once(res, "drain", { signal: upstreamAbort.signal });
    }
    for (const event of parser.push(chunk)) tracker.onEvent(event);
  }
  for (const event of parser.end()) tracker.onEvent(event);
} catch (error) {
  transportError = error; // istemci koptu, upstream sustu ya da bağlantı kesildi
} finally {
  clearTimeout(idleTimer);
  res.end();
}

await finalize({
  ledger,
  price,
  tracker,
  transportError,
  idleTimedOut,
  record: {
    requestId,
    upstreamRequestId,
    priceVersion: PRICE_TABLE.version,
  },
});
Tam kod: gateway.js
// gateway.js: Node.js 20+ (yerleşik fetch), bağımlılık yok.
import { randomUUID } from "node:crypto";
import { once } from "node:events";
import { SseParser } from "./sse-parser.js";
import { createUsageTracker } from "./anthropic-usage.js";
import { costNanoUsd, PRICE_TABLE } from "./cost.js";

const MAX_BODY_BYTES = 32 * 1024 * 1024; // Messages API istek sınırı
const IDLE_TIMEOUT_MS = 90_000; // iki parça arasında beklenecek en uzun süre
const MAX_RETRY_AFTER_S = 5; // daha uzun bekleme istenirse kiracıya 503 dön
const ALLOWED_BETAS = new Set(); // izin verilen anthropic-beta değerleri

class BodyTooLargeError extends Error {}

export function createGateway(options) {
  const handle = createHandler(options);
  return (req, res) =>
    handle(req, res).catch(error => {
      // Yakalanmayan hata süreci düşürmesin
      console.error("gateway_error", error);
      if (!res.headersSent) sendError(res, 500, "gateway_error");
      else res.destroy();
    });
}

function createHandler({
  upstreamUrl,
  apiKey,
  authenticate,
  countTokens,
  ledger,
  idleTimeoutMs = IDLE_TIMEOUT_MS,
}) {
  return async function handle(req, res) {
    const requestId = randomUUID();
    res.setHeader("x-request-id", requestId);

    // İstemci ayrılırsa üretimi sürdürmemek için upstream isteği iptal edilir.
    // Dinleyici ilk await'ten önce kurulur; aksi halde erken kopma kaçırılır.
    const upstreamAbort = new AbortController();
    res.on("close", () => {
      if (!res.writableFinished) upstreamAbort.abort();
    });

    const tenant = await authenticate(req); // kiracının anahtarı, sağlayıcınınki değil
    if (!tenant) return sendError(res, 401, "authentication_error");

    let body;
    try {
      body = JSON.parse(await readBody(req, MAX_BODY_BYTES));
    } catch (error) {
      if (error instanceof BodyTooLargeError) {
        return sendError(res, 413, "request_too_large");
      }
      return sendError(res, 400, "invalid_request_error");
    }

    // Politika: model izni, çıktı tavanı ve yalnız akışlı istek
    const price = PRICE_TABLE.models[body.model];
    if (!price || !tenant.models.includes(body.model)) {
      return sendError(res, 403, "model_not_allowed");
    }
    if (body.stream !== true) return sendError(res, 400, "stream_required");
    const requested = body.max_tokens ?? tenant.maxTokens;
    if (!Number.isInteger(requested) || requested < 1) {
      return sendError(res, 400, "invalid_max_tokens");
    }
    body.max_tokens = Math.min(requested, tenant.maxTokens);

    // En kötü durum: tüm girdi en pahalı girdi sınıfından, çıktı max_tokens'a kadar
    let inputEstimate;
    try {
      inputEstimate = await countTokens(body);
    } catch {
      // Bütçe kontrolü yapılamıyorsa istek geçirilmez
      return sendError(res, 503, "budget_check_unavailable");
    }
    const reserve =
      inputEstimate * price.cacheWrite1h + body.max_tokens * price.output;
    const reserved = await ledger.reserve({
      requestId,
      tenantId: tenant.id,
      model: body.model,
      reserve,
    });
    if (!reserved) return sendError(res, 402, "budget_exceeded");

    let upstream;
    try {
      upstream = await fetchWithRetry(upstreamUrl, {
        method: "POST",
        headers: {
          "content-type": "application/json",
          "x-api-key": apiKey,
          "anthropic-version": "2023-06-01",
          ...pickBetaHeader(req.headers["anthropic-beta"]),
        },
        body: JSON.stringify(body),
        signal: upstreamAbort.signal,
      });
    } catch {
      await ledger.release({ requestId });
      return sendError(res, 502, "upstream_unavailable");
    }

    const upstreamRequestId = upstream.headers.get("request-id");
    if (upstream.status !== 200) {
      // Akış başlamadı, usage dönmedi: rezervasyon geri bırakılır
      await ledger.release({ requestId, upstreamRequestId });
      const errorBody = await upstream.text();
      if (isRequestError(upstream.status, errorBody)) {
        // İsteğin kendisinden kaynaklanan hata kiracıya aynen döner
        res.writeHead(upstream.status, { "content-type": "application/json" });
        return res.end(errorBody);
      }
      // Kimlik, bakiye, harcama sınırı ve kapasite hataları sağlayıcı hesabına aittir
      const busy = upstream.status === 429 || upstream.status === 529;
      const retryAfter = upstream.headers.get("retry-after");
      if (busy && retryAfter) res.setHeader("retry-after", retryAfter);
      return sendError(res, busy ? 503 : 502, "upstream_unavailable");
    }

    res.writeHead(200, {
      "content-type": "text/event-stream",
      "cache-control": "no-cache",
      "x-accel-buffering": "no", // nginx bu yanıtı tamponlamasın
    });

    const parser = new SseParser();
    const tracker = createUsageTracker();
    let transportError = null;
    let idleTimedOut = false;
    let idleTimer;
    const armIdleTimer = () => {
      clearTimeout(idleTimer);
      idleTimer = setTimeout(() => {
        idleTimedOut = true; // ping dahil hiçbir bayt gelmedi
        upstreamAbort.abort();
      }, idleTimeoutMs);
    };

    try {
      armIdleTimer();
      for await (const chunk of upstream.body) {
        armIdleTimer();
        // Baytlar olduğu gibi aktarılır; ayrıştırma yalnız ölçüm içindir
        if (!res.write(chunk)) {
          await once(res, "drain", { signal: upstreamAbort.signal });
        }
        for (const event of parser.push(chunk)) tracker.onEvent(event);
      }
      for (const event of parser.end()) tracker.onEvent(event);
    } catch (error) {
      transportError = error; // istemci koptu, upstream sustu ya da bağlantı kesildi
    } finally {
      clearTimeout(idleTimer);
      res.end();
    }

    await finalize({
      ledger,
      price,
      tracker,
      transportError,
      idleTimedOut,
      record: {
        requestId,
        upstreamRequestId,
        priceVersion: PRICE_TABLE.version,
      },
    });
  };
}

async function finalize({
  ledger,
  price,
  tracker,
  transportError,
  idleTimedOut,
  record,
}) {
  const { usage, completed, error, stopReason } = tracker.state;
  const base = { ...record, usage, stopReason };

  let cost = null;
  try {
    cost = costNanoUsd(usage, price);
  } catch {
    cost = null; // usage eksik: maliyet bilinmiyor
  }

  try {
    if (completed && !error && !transportError && cost !== null) {
      return await ledger.settle({ ...base, costNanoUsd: cost });
    }
    // Kesinleşmeyen kayıt: elimizdeki sayı yalnız alt sınırdır
    let reason = "missing_message_stop";
    if (error) reason = error.type;
    else if (idleTimedOut) reason = "upstream_idle";
    else if (transportError) reason = "stream_interrupted";
    else if (cost === null) reason = "usage_incomplete";
    return await ledger.markUnreconciled({
      ...base,
      lowerBoundNanoUsd: cost,
      reason,
    });
  } catch (ledgerError) {
    // Yanıt çoktan gönderildi; kayıt kaybolmasın diye kalıcı bir kuyruğa yazılmalı
    console.error("ledger_write_failed", JSON.stringify(base), ledgerError);
  }
}

// Yalnız ilk bayt aktarılmadan önce ve geçici hatalarda yeniden dene
async function fetchWithRetry(url, init, attempts = 3) {
  for (let attempt = 1; ; attempt++) {
    const response = await fetch(url, init);
    const retryAfter = Number(response.headers.get("retry-after") ?? 0);
    const retryable =
      response.status >= 500 || (response.status === 429 && retryAfter > 0);
    if (!retryable || attempt === attempts || retryAfter > MAX_RETRY_AFTER_S) {
      return response;
    }

    await response.body?.cancel();
    const backoffMs = retryAfter > 0 ? retryAfter * 1000 : 2 ** attempt * 250;
    await sleep(backoffMs + Math.random() * 250, init.signal);
  }
}

// 413 ve doğrulama kaynaklı 400 isteğin kendisine aittir. Anthropic, sizin
// tanımladığınız harcama sınırına ulaşıldığında da 400 döner; bu hesap durumudur.
function isRequestError(status, text) {
  if (status === 413) return true;
  if (status !== 400) return false;
  try {
    const message = JSON.parse(text).error?.message ?? "";
    return !message.startsWith("You have reached your specified");
  } catch {
    return false;
  }
}

function pickBetaHeader(value) {
  const allowed = String(value ?? "")
    .split(",")
    .map(item => item.trim())
    .filter(item => ALLOWED_BETAS.has(item));
  return allowed.length ? { "anthropic-beta": allowed.join(",") } : {};
}

async function readBody(req, limit) {
  const chunks = [];
  let size = 0;
  for await (const chunk of req) {
    size += chunk.length;
    if (size > limit) throw new BodyTooLargeError();
    chunks.push(chunk);
  }
  return Buffer.concat(chunks).toString("utf8");
}

function sendError(res, status, type) {
  res.writeHead(status, { "content-type": "application/json" });
  res.end(JSON.stringify({ type: "error", error: { type } }));
}

function sleep(ms, signal) {
  return new Promise((resolve, reject) => {
    const timer = setTimeout(resolve, ms);
    signal?.addEventListener(
      "abort",
      () => {
        clearTimeout(timer);
        reject(signal.reason);
      },
      { once: true }
    );
  });
}

Kodda üç karar özellikle bilinçlidir:

Yarım kalan akışlar

Bir akış dört şekilde yarım kalabilir ve her biri farklı ele alınır.

İstemci bağlantıyı kapatır: Kullanıcı sekmeyi kapatır ya da “durdur” düğmesine basar. Örnekteki gateway upstream isteğini iptal eder; böylece kimsenin okumayacağı çıktı için üretim sürmez. Bu kararın bedeli, kesin kullanım bilgisinin gelmemesidir. 10 Ekim 2026 itibarıyla Anthropic ve OpenAI’nin API belgelerinde, senkron bir akışta bağlantı kapandığında üretimin tam olarak ne zaman durduğuna ve o ana kadar üretilen token’ların nasıl faturalandığına dair açık bir tanım bulunmuyor. Bu yüzden kopan istek “bedelsiz” değil “kesinleşmemiş” sayılır; message_start içindeki girdi sayısı ve o ana kadarki çıktı alt sınır olarak kaydedilir. Alternatif, istemci kopsa bile upstream akışını sonuna kadar okumaktır: kesin kullanım bilgisi gelir ama tüm çıktı faturalanır. Yanıtın kendisi saklanacaksa (örneğin arka planda çalışan bir iş) bu tercih anlamlıdır. Her iki durumda da max_tokens tavanı, iptalin geç işlediği senaryoda maliyeti sınırlar. OpenAI’nin Responses API’sindeki arka plan modu ise yanıtı kimliğiyle iptal etmeye izin veren açık bir uç nokta sunar.

Akışın ortasında error olayı gelir: Durum kodu zaten 200 olarak gönderildiği için değiştirilemez. Gateway olayı istemciye olduğu gibi iletir, kaydı kesinleşmemiş olarak işaretler ve isteği kendi başına tekrarlamaz. Anthropic’in belgesi yarıda kalan bir yanıtın nasıl sürdürüleceğini anlatır: alınan kısım saklanır ve devam ettirme isteği gönderilir; araç kullanımı ve düşünme blokları kısmen kurtarılamaz. Bu karar ürün mantığına aittir ve istemci tarafında verilmelidir.

Upstream susar: Belirlenen süre boyunca hiç bayt gelmez. Boşta kalma zamanlayıcısı upstream isteğini iptal eder ve kayıt upstream_idle nedeniyle kesinleşmemiş olarak işaretlenir.

message_stop gelir ama kullanım bilgisi eksiktir: Maliyet hesaplanamaz, kayıt usage_incomplete nedeniyle mutabakata kalır. Bu durum nadirdir ama 0 yazmanın en kolay göründüğü yerdir.

Mutabakat

Gateway’in kendi kayıtları ne kadar dikkatli tutulursa tutulsun, faturanın tek doğru kaynağı sağlayıcıdır. Anthropic’in Usage & Cost Admin API’si kuruluş kullanımını 1 dakikalık, 1 saatlik ya da 1 günlük aralıklarla; API anahtarı, çalışma alanı (workspace) ve modele göre gruplanmış olarak; önbelleksiz girdi, önbellekten okunan girdi, önbelleğe yazılan girdi ve çıktı ayrımıyla döndürür. Maliyet raporu günlük ve USD cinsindendir. Belgeye göre veriler genellikle istek tamamlandıktan sonraki 5 dakika içinde görünür. Bu API kuruluş düzeyinde yönetici yetkisi (Admin API anahtarı ya da org:admin yetkili bir token) gerektirir, çalışma alanı anahtarlarıyla çalışmaz ve bireysel hesaplarda kullanılamaz.

Mutabakatı kolaylaştıran birkaç uygulama:

Güvenlik ve gizlilik

Gözlemlenebilirlik

OpenTelemetry’nin üretken yapay zekâ semantik kuralları (Development aşamasında) kullanım için gen_ai.usage.input_tokens, gen_ai.usage.output_tokens, gen_ai.usage.cache_read.input_tokens ve gen_ai.usage.cache_write.input_tokens niteliklerini, yanıtı üreten model için gen_ai.response.model niteliğini tanımlar. Anthropic için toplam girdinin üç alanın toplamı olarak hesaplanması gerektiğini yukarıda gördük; kurallar henüz kararlı olmadığından nitelik adlarını sürüm değişikliklerinde kontrol edin.

İzlenmesi faydalı ölçümler:

Test stratejisi

Ayrıştırıcıyı test etmenin en etkili yolu, aynı akışı mümkün olan her noktadan bölüp her seferinde aynı sonucu aldığını doğrulamaktır. Bu test, çok baytlı Türkçe karakterlerin ve \r\n çiftinin parça sınırına denk geldiği durumları kendiliğinden kapsar.

import assert from "node:assert/strict";
import { SseParser } from "./sse-parser.js";

const raw = new TextEncoder().encode(
  'event: content_block_delta\r\ndata: {"text":"İstanbul\'da ılık çay"}\r\n\r\n'
);
const whole = new SseParser();
const expected = [...whole.push(raw), ...whole.end()];
assert.equal(expected.length, 1);

for (let cut = 1; cut < raw.length; cut++) {
  const parser = new SseParser();
  const events = [
    ...parser.push(raw.subarray(0, cut)),
    ...parser.push(raw.subarray(cut)),
    ...parser.end(),
  ];
  assert.deepEqual(events, expected, `bölme noktası: ${cut}`);
}

Bu yazıdaki kod, bayt bölme testinin yanında sahte bir upstream sunucusuyla şu uçtan uca senaryolarda denendi: istemciye giden baytların upstream ile birebir aynı olması, 529 sonrası yeniden deneme, akış ortasında error olayı, istemci kopunca upstream’in iptal edilmesi, upstream’in susması, uzun retry-after, eksik kullanım bilgisi, harcama sınırı 400’ünün gizlenmesi, token sayımının ve kayıt yazımının başarısız olması. Gerçek sağlayıcıdan bir kez kaydedilen akışları (anahtarlar ve içerik temizlenerek) test verisi olarak saklamak, sağlayıcı yeni bir olay türü eklediğinde ayrıştırıcının nasıl davrandığını da görünür kılar.

Üretime almadan önce kontrol listesi

Sonuç

LLM gateway’in değeri, sağlayıcıyı uygulamalardan saklamasından çok, her isteğin maliyetini kanıtlanabilir şekilde kayda geçirmesinden gelir. Bunun için akış değiştirilmeden aktarılmalı, kullanım bilgisi sağlayıcının kendi kurallarıyla okunmalı, maliyet sürümlü fiyatlarla tam sayı olarak hesaplanmalı ve bütçe istekten önce bloke edilmelidir. Yarım kalan akışlar kaçınılmazdır; doğru gateway bunları 0 olarak değil, mutabakatla kapanacak açık kayıtlar olarak tutar.

Birincil kaynaklar