Files
llmgw/internal/sim/server_test.go
T
ilkerandClaude Opus 5 e07bc46c57 Faz 0: iskelet ve sim-engine
Plan v0.2'nin Faz 0 çıktısı. Yazılan sıraya göre:

- cobra tabanlı CLI iskeleti; server/agent/ctl komutları henüz stub.
- internal/sim: OpenAI uyumlu sahte inference engine. Router'ı GPU olmadan
  gerçekçi koşullarda test edebilmek için gerçek bir engine'in router
  açısından önemli davranışlarını modeller:
    * continuous batching ve KV blok muhasebesi (head-of-line blocking dahil)
    * zincirlenmiş blok hash'leri + LRU ile otomatik prefix cache
    * batch büyüdükçe artan token gecikmesi
    * istemci koptuğunda isteğin düşürülüp blokların bırakılması
    * vLLM metrik adlarıyla /metrics (plan §7.6 engine kontratı)
- Testler: prefix hash zincirlemesi, LRU tahliyesi, iptal propagasyonu,
  kuyruklama, SSE formatı, usage/cached_tokens, metriklerin gerçek
  Prometheus parser'ıyla okunabilirliği.
- Gitea Actions CI: gofmt, vet, race detector.

Modül yolu gitea.mynodes.xyz/ilker/llmgw; `make rename` ile değiştirilebilir.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 01:00:03 +02:00

384 lines
10 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package sim
import (
"bufio"
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/prometheus/common/expfmt"
"github.com/prometheus/common/model"
)
func newTestServer(t *testing.T, cfg Config) *httptest.Server {
t.Helper()
eng := New(cfg)
srv := httptest.NewServer(NewServer(eng, nil))
t.Cleanup(func() {
srv.Close()
eng.Close()
})
return srv
}
func postJSON(t *testing.T, url string, body any) *http.Response {
t.Helper()
b, err := json.Marshal(body)
if err != nil {
t.Fatal(err)
}
resp, err := http.Post(url, "application/json", bytes.NewReader(b)) //nolint:noctx // test
if err != nil {
t.Fatal(err)
}
return resp
}
func TestChatCompletionsBuffered(t *testing.T) {
srv := newTestServer(t, testConfig())
resp := postJSON(t, srv.URL+"/v1/chat/completions", map[string]any{
"model": "sim-model",
"messages": []map[string]string{{"role": "user", "content": "merhaba"}},
"max_tokens": 7,
})
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("durum %d", resp.StatusCode)
}
var out struct {
Object string `json:"object"`
Choices []struct {
Message struct {
Role string `json:"role"`
Content string `json:"content"`
} `json:"message"`
FinishReason string `json:"finish_reason"`
} `json:"choices"`
Usage usage `json:"usage"`
}
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
t.Fatal(err)
}
if out.Object != "chat.completion" {
t.Errorf("object=%q", out.Object)
}
if len(out.Choices) != 1 {
t.Fatalf("choices=%d", len(out.Choices))
}
if n := len(strings.Fields(out.Choices[0].Message.Content)); n != 7 {
t.Errorf("beklenen 7 token, alınan %d", n)
}
if out.Usage.CompletionTokens != 7 {
t.Errorf("usage.completion_tokens=%d", out.Usage.CompletionTokens)
}
if out.Usage.TotalTokens != out.Usage.PromptTokens+out.Usage.CompletionTokens {
t.Error("usage toplamı tutarsız")
}
}
// sseEvent, SSE akışından çözülmüş tek bir olay.
type sseEvent struct {
raw string
data map[string]any
}
func readSSE(t *testing.T, body *bufio.Reader) []sseEvent {
t.Helper()
var out []sseEvent
for {
line, err := body.ReadString('\n')
if err != nil {
return out
}
line = strings.TrimRight(line, "\r\n")
if !strings.HasPrefix(line, "data: ") {
continue
}
payload := strings.TrimPrefix(line, "data: ")
ev := sseEvent{raw: payload}
if payload != "[DONE]" {
if err := json.Unmarshal([]byte(payload), &ev.data); err != nil {
t.Fatalf("SSE yükü çözülemedi: %v (%q)", err, payload)
}
}
out = append(out, ev)
}
}
func TestChatCompletionsStreaming(t *testing.T) {
srv := newTestServer(t, testConfig())
resp := postJSON(t, srv.URL+"/v1/chat/completions", map[string]any{
"model": "sim-model",
"messages": []map[string]string{{"role": "user", "content": "merhaba"}},
"max_tokens": 5,
"stream": true,
"stream_options": map[string]any{"include_usage": true},
})
defer resp.Body.Close()
if ct := resp.Header.Get("Content-Type"); ct != "text/event-stream" {
t.Errorf("Content-Type=%q", ct)
}
events := readSSE(t, bufio.NewReader(resp.Body))
if len(events) == 0 {
t.Fatal("hiç SSE olayı gelmedi")
}
if last := events[len(events)-1]; last.raw != "[DONE]" {
t.Errorf("akış [DONE] ile bitmedi: %q", last.raw)
}
var contentTokens, usageChunks, finishChunks int
for _, ev := range events {
if ev.raw == "[DONE]" {
continue
}
if _, ok := ev.data["usage"]; ok {
usageChunks++
continue
}
choices, _ := ev.data["choices"].([]any)
if len(choices) == 0 {
continue
}
c, _ := choices[0].(map[string]any)
if delta, ok := c["delta"].(map[string]any); ok {
if _, has := delta["content"]; has {
contentTokens++
}
}
if fr, ok := c["finish_reason"].(string); ok && fr == "length" {
finishChunks++
}
}
if contentTokens != 5 {
t.Errorf("beklenen 5 içerik chunk'ı, alınan %d", contentTokens)
}
if finishChunks != 1 {
t.Errorf("beklenen 1 finish_reason chunk'ı, alınan %d", finishChunks)
}
if usageChunks != 1 {
t.Errorf("include_usage istendi ama %d usage chunk'ı geldi", usageChunks)
}
}
func TestStreamingUsageIstenmezseGonderilmez(t *testing.T) {
srv := newTestServer(t, testConfig())
resp := postJSON(t, srv.URL+"/v1/chat/completions", map[string]any{
"messages": []map[string]string{{"role": "user", "content": "x"}},
"max_tokens": 2,
"stream": true,
})
defer resp.Body.Close()
for _, ev := range readSSE(t, bufio.NewReader(resp.Body)) {
if ev.raw == "[DONE]" {
continue
}
if _, ok := ev.data["usage"]; ok {
t.Error("istenmediği halde usage chunk'ı gönderildi")
}
}
}
func TestIstemciKopmasiEngineeYansir(t *testing.T) {
// Faz 1'in çıkış kriterinin temeli: istemci koptuğunda GPU boşa çalışmamalı.
cfg := testConfig()
cfg.DecodeTokensPerSec = 20
eng := New(cfg)
defer eng.Close()
srv := httptest.NewServer(NewServer(eng, nil))
defer srv.Close()
ctx, cancel := context.WithCancel(context.Background())
body, _ := json.Marshal(map[string]any{
"messages": []map[string]string{{"role": "user", "content": "merhaba"}},
"max_tokens": 5000,
"stream": true,
})
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
srv.URL+"/v1/chat/completions", bytes.NewReader(body))
if err != nil {
t.Fatal(err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
// İlk token'ı bekle, sonra istemciyi kopar.
waitFor(t, 2*time.Second, func() bool { return eng.Stats().Running == 1 })
cancel()
_ = resp.Body.Close()
waitFor(t, 3*time.Second, func() bool {
s := eng.Stats()
return s.Running == 0 && s.KVCacheUsage == 0
})
}
func TestCompletionsUcNoktasi(t *testing.T) {
srv := newTestServer(t, testConfig())
resp := postJSON(t, srv.URL+"/v1/completions", map[string]any{
"model": "sim-model", "prompt": "merhaba", "max_tokens": 3,
})
defer resp.Body.Close()
var out struct {
Object string `json:"object"`
Choices []struct {
Text string `json:"text"`
} `json:"choices"`
}
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
t.Fatal(err)
}
if out.Object != "text_completion" {
t.Errorf("object=%q", out.Object)
}
if n := len(strings.Fields(out.Choices[0].Text)); n != 3 {
t.Errorf("beklenen 3 token, alınan %d", n)
}
}
func TestModelsVeHealth(t *testing.T) {
cfg := testConfig()
cfg.Model = "llama-3.3-70b"
srv := newTestServer(t, cfg)
resp, err := http.Get(srv.URL + "/health") //nolint:noctx // test
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Errorf("/health durum %d", resp.StatusCode)
}
resp, err = http.Get(srv.URL + "/v1/models") //nolint:noctx // test
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
var out struct {
Data []struct {
ID string `json:"id"`
} `json:"data"`
}
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
t.Fatal(err)
}
if len(out.Data) != 1 || out.Data[0].ID != "llama-3.3-70b" {
t.Errorf("/v1/models beklenen modeli listelemedi: %+v", out.Data)
}
}
// TestMetriklerPrometheusIleParseEdilebilir, §7.6'daki engine kontratını doğrular.
// Router bu metrikleri gerçek bir Prometheus parser'ıyla okuyacak; format
// bozuksa yönlendirme kör kalır.
func TestMetriklerPrometheusIleParseEdilebilir(t *testing.T) {
srv := newTestServer(t, testConfig())
// Biraz trafik üret ki sayaçlar dolsun.
resp := postJSON(t, srv.URL+"/v1/chat/completions", map[string]any{
"messages": []map[string]string{{"role": "user", "content": "merhaba dunya"}},
"max_tokens": 4,
})
resp.Body.Close()
mresp, err := http.Get(srv.URL + "/metrics") //nolint:noctx // test
if err != nil {
t.Fatal(err)
}
defer mresp.Body.Close()
// TextParser'ın sıfır değeri geçersiz; şema açıkça verilmeli.
// Legacy şema daha sıkı: metrik adlarımızın klasik Prometheus kurallarına
// uyduğunu da doğrulamış oluyoruz (vLLM adlarındaki ':' legacy'de de geçerli).
parser := expfmt.NewTextParser(model.LegacyValidation)
families, err := parser.TextToMetricFamilies(mresp.Body)
if err != nil {
t.Fatalf("metrikler parse edilemedi: %v", err)
}
// §7.6 zorunlu üçlüsü.
for _, name := range []string{
"vllm:num_requests_running",
"vllm:num_requests_waiting",
"vllm:kv_cache_usage_perc",
} {
if _, ok := families[name]; !ok {
t.Errorf("zorunlu metrik eksik: %s", name)
}
}
// Opsiyoneller ve histogramlar.
for _, name := range []string{
"vllm:cache_config_info",
"vllm:prefix_cache_queries_total",
"vllm:prefix_cache_hits_total",
"vllm:time_to_first_token_seconds",
} {
if _, ok := families[name]; !ok {
t.Errorf("metrik eksik: %s", name)
}
}
if f, ok := families["vllm:generation_tokens_total"]; ok {
if got := f.Metric[0].GetCounter().GetValue(); got != 4 {
t.Errorf("generation_tokens_total=%v, beklenen 4", got)
}
} else {
t.Error("vllm:generation_tokens_total eksik")
}
}
// TestUsageCachedTokensRaporlanir, prefix cache etkisinin istemciye ulaştığını
// doğrular. Router ileride bunu kullanmayacak olsa da, cache'in gerçekten
// çalıştığını uçtan uca gösteren tek sinyal bu.
func TestUsageCachedTokensRaporlanir(t *testing.T) {
srv := newTestServer(t, testConfig())
body := map[string]any{
"messages": []map[string]string{{"role": "user", "content": strings.Repeat("ortak onek. ", 100)}},
"max_tokens": 3,
}
// İlk istek cache'i doldurur.
resp := postJSON(t, srv.URL+"/v1/chat/completions", body)
resp.Body.Close()
// İkincisi aynı ön eki kullanmalı.
resp = postJSON(t, srv.URL+"/v1/chat/completions", body)
defer resp.Body.Close()
var out struct {
Usage usage `json:"usage"`
}
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
t.Fatal(err)
}
if out.Usage.PromptTokensDetails.CachedTokens == 0 {
t.Error("ikinci istekte cached_tokens 0; prefix cache istemciye raporlanmıyor")
}
if out.Usage.PromptTokensDetails.CachedTokens > out.Usage.PromptTokens {
t.Errorf("cached_tokens (%d) prompt_tokens'tan (%d) büyük",
out.Usage.PromptTokensDetails.CachedTokens, out.Usage.PromptTokens)
}
}