mirror of
https://github.com/lukaszraczylo/traefik-llmgateway.git
synced 2026-10-18 05:47:38 +00:00
test: v0.2 integration coverage
Extends the integration suite (task 5) to exercise retry, cache, images/audio, and admin under real Traefik+Yaegi:
- mock/main.go: a /flaky/v1/chat/completions endpoint failing the first
request per process with 503 then succeeding (atomic counter, response
carries mock_attempt for verification); /v1/images/generations returning
a canned OpenAI images response; /v1/audio/speech returning binary bytes
under audio/mpeg; gemini mode's :predict returning canned Imagen
predictions.
- dynamic.yml.tmpl: retry{enabled:true,attempts:1}, cache{enabled:true,
ttl:2m}, admin{enabled:true}; a flaky provider pointed at the new mock
endpoint; an admin:true inline user; alice and admin both given a
generous-but-real per-user limits block so their own request counters
are trackable via the usage API.
- integration_test.go: TestRetryFlakyRecovers (client 200, exactly two
upstream hits proven via mock_attempt), TestResponseCache (miss then
hit, tokensPerDay unchanged across the hit via the admin usage API),
TestImagesGenerations (openai native forward + gemini Imagen
translation), TestAudioSpeech (binary round trip, Content-Type
forwarded), TestAdminDashboard (unauthenticated shell, admin vs
non-admin gate, usage reflecting this suite's own traffic).
make integration exits 0 (verified across multiple fresh runs).
This commit is contained in:
@@ -27,6 +27,7 @@ import (
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -71,6 +72,13 @@ func newServerMux(mode string) (*http.ServeMux, error) {
|
||||
case "openai":
|
||||
mux.HandleFunc("/v1/models", handleOpenAIModels)
|
||||
mux.HandleFunc("/v1/chat/completions", handleOpenAIChat)
|
||||
// flaky/v1/chat/completions backs the retry integration test (task
|
||||
// 5): the plugin's "flaky" provider points its baseUrl at this
|
||||
// mode's own address plus "/flaky", so a.baseURL+"/v1/chat/
|
||||
// completions" (provider_openai.go) resolves to exactly this path.
|
||||
mux.HandleFunc("/flaky/v1/chat/completions", handleFlakyOpenAIChat)
|
||||
mux.HandleFunc("/v1/images/generations", handleOpenAIImages)
|
||||
mux.HandleFunc("/v1/audio/speech", handleOpenAIAudioSpeech)
|
||||
case "anthropic":
|
||||
mux.HandleFunc("/v1/models", handleAnthropicModels)
|
||||
mux.HandleFunc("/v1/messages", handleAnthropicMessages)
|
||||
@@ -200,6 +208,89 @@ func handleOpenAIChat(w http.ResponseWriter, r *http.Request) {
|
||||
sw.write("", "[DONE]")
|
||||
}
|
||||
|
||||
// flakyHits counts every request handleFlakyOpenAIChat has served across
|
||||
// this process's lifetime. It is package-level, process-lifetime state
|
||||
// deliberately, not reset per request: the retry integration test relies
|
||||
// on exactly the FIRST request this mock process ever receives on
|
||||
// /flaky/v1/chat/completions failing, and every one after succeeding, so
|
||||
// a fresh mockopenai container (one per `make integration` run) always
|
||||
// starts the scenario from a clean slate. Re-running the integration
|
||||
// suite against an already-up stack (`make integration-keep`, without
|
||||
// restarting mockopenai) will NOT reproduce the retry test a second time
|
||||
// — the same non-idempotence flushRedis exists to paper over for the
|
||||
// rate-limit counters has no equivalent reset here, since there is no
|
||||
// admin endpoint on this fixture server to clear it.
|
||||
var flakyHits int32
|
||||
|
||||
// handleFlakyOpenAIChat answers the first request this process ever
|
||||
// receives with HTTP 503 (a transient failure retryPolicy.isTransient
|
||||
// classifies as retryable, retry.go) and every request after that with a
|
||||
// normal chat completion response carrying an extra top-level
|
||||
// "mock_attempt" field set to this handler's own call count — the
|
||||
// unified route's openai-type adapter forwards a JSON response body
|
||||
// verbatim (forwardJSON, provider_openai.go), so this field survives
|
||||
// straight through to the client and lets the retry integration test
|
||||
// prove the mock was hit exactly twice without needing to inspect
|
||||
// response headers forwardJSON never copies.
|
||||
func handleFlakyOpenAIChat(w http.ResponseWriter, r *http.Request) {
|
||||
n := atomic.AddInt32(&flakyHits, 1)
|
||||
if n == 1 {
|
||||
http.Error(w, `{"error":{"message":"mock flaky failure","type":"server_error"}}`, http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
|
||||
body := decodeJSONBody(r)
|
||||
model, _ := body["model"].(string)
|
||||
if model == "" {
|
||||
model = "flaky-mock"
|
||||
}
|
||||
writeJSON(w, map[string]any{
|
||||
"id": "chatcmpl-flaky",
|
||||
"object": "chat.completion",
|
||||
"model": model,
|
||||
"mock_attempt": n,
|
||||
"choices": []map[string]any{
|
||||
{
|
||||
"index": 0,
|
||||
"message": map[string]any{"role": "assistant", "content": "mock flaky response"},
|
||||
"finish_reason": "stop",
|
||||
},
|
||||
},
|
||||
"usage": map[string]any{"prompt_tokens": 10, "completion_tokens": 5},
|
||||
})
|
||||
}
|
||||
|
||||
// handleOpenAIImages answers POST /v1/images/generations with a canned
|
||||
// OpenAI images.generations response — the images integration test's
|
||||
// openai-type case is a native forward (routes_media.go), so whatever
|
||||
// this returns reaches the client unmodified.
|
||||
func handleOpenAIImages(w http.ResponseWriter, r *http.Request) {
|
||||
_ = decodeJSONBody(r) // request shape isn't asserted on; draining it is enough
|
||||
writeJSON(w, map[string]any{
|
||||
"created": 1755600000,
|
||||
"data": []map[string]any{
|
||||
{"b64_json": "bW9jay1vcGVuYWktaW1hZ2UtYnl0ZXM=", "revised_prompt": "mock revised prompt"},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// mockAudioBytes is the fixed binary payload handleOpenAIAudioSpeech
|
||||
// returns — deliberately not valid MP3 data, since nothing in the
|
||||
// gateway's audioSpeech path (a pure byte-for-byte stream,
|
||||
// provider_openai.go) ever parses it; only its content-type and byte
|
||||
// equality on round trip matter to the audio integration test.
|
||||
var mockAudioBytes = []byte("MOCK-AUDIO-BYTES-0123456789-DISTINCTIVE-PAYLOAD")
|
||||
|
||||
// handleOpenAIAudioSpeech answers POST /v1/audio/speech with mockAudioBytes
|
||||
// under Content-Type audio/mpeg, mirroring a real TTS endpoint's binary
|
||||
// response.
|
||||
func handleOpenAIAudioSpeech(w http.ResponseWriter, r *http.Request) {
|
||||
_ = decodeJSONBody(r)
|
||||
w.Header().Set("Content-Type", "audio/mpeg")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write(mockAudioBytes)
|
||||
}
|
||||
|
||||
// --- anthropic mode ----------------------------------------------------------
|
||||
|
||||
func handleAnthropicModels(w http.ResponseWriter, _ *http.Request) {
|
||||
@@ -285,6 +376,17 @@ func handleGeminiGenerate(w http.ResponseWriter, r *http.Request) {
|
||||
sw.write("", f) // Gemini's SSE stream carries no "event:" field.
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
case "predict":
|
||||
// Backs the images integration test's gemini case: the gateway's
|
||||
// geminiAdapter.imagesGeneration (provider_gemini.go) POSTs to
|
||||
// {base}/v1beta/models/{model}:predict and translates this
|
||||
// Imagen-shaped response into OpenAI's images.generations shape
|
||||
// (translate_gemini_images.go) before it ever reaches the client.
|
||||
writeJSON(w, map[string]any{
|
||||
"predictions": []map[string]any{
|
||||
{"bytesBase64Encoded": "bW9jay1nZW1pbmktaW1hZ2UtYnl0ZXM="},
|
||||
},
|
||||
})
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user