From c0bd7167ca4fb89a5d50ed268f3943493e96f3a9 Mon Sep 17 00:00:00 2001 From: "Jakub A. W" Date: Thu, 22 Jan 2026 16:14:26 +0100 Subject: [PATCH 1/8] tests: Prepared and executed the testing strategy --- .claude/settings.local.json | 5 +- .github/workflows/test.yml | 18 +- .gitignore | 1 + Makefile | 27 +- cmd/recordapi/main.go | 275 +++++++ docs/FUTURE_ARCHITECTURE.md | 123 +++ docs/TESTING_STRATEGY.md | 241 ++++++ go.mod | 42 + go.sum | 155 ++++ tests/contract/README.md | 419 ++++++++++ tests/contract/anthropic_test.go | 259 +++++++ tests/contract/doc.go | 6 + tests/contract/gemini_test.go | 149 ++++ tests/contract/groq_test.go | 141 ++++ tests/contract/main_test.go | 94 +++ tests/contract/openai_test.go | 221 ++++++ .../contract/testdata/anthropic/messages.json | 18 + .../anthropic/messages_extended_thinking.json | 30 + .../anthropic/messages_multi_turn.json | 25 + .../anthropic/messages_multimodal.json | 25 + .../testdata/anthropic/messages_stream.txt | 20 + .../anthropic/messages_with_params.json | 25 + .../anthropic/messages_with_tools.json | 33 + .../testdata/gemini/chat_completion.json | 21 + .../gemini/chat_completion_stream.txt | 3 + .../testdata/gemini/chat_with_params.json | 21 + .../testdata/gemini/chat_with_tools.json | 30 + tests/contract/testdata/gemini/models.json | 323 ++++++++ .../testdata/groq/chat_completion.json | 33 + .../testdata/groq/chat_completion_stream.txt | 11 + .../testdata/groq/chat_with_params.json | 33 + .../testdata/groq/chat_with_tools.json | 42 + tests/contract/testdata/groq/models.json | 205 +++++ .../testdata/openai/chat_completion.json | 36 + .../openai/chat_completion_reasoning.json | 35 + .../openai/chat_completion_stream.txt | 13 + .../testdata/openai/chat_json_mode.json | 36 + .../testdata/openai/chat_multi_turn.json | 36 + .../testdata/openai/chat_multimodal.json | 36 + .../testdata/openai/chat_with_params.json | 36 + .../testdata/openai/chat_with_tools.json | 46 ++ tests/contract/testdata/openai/models.json | 719 ++++++++++++++++++ .../testdata/xai/chat_completion.json | 38 + .../testdata/xai/chat_completion_stream.txt | 587 ++++++++++++++ .../testdata/xai/chat_with_params.json | 38 + tests/contract/testdata/xai/models.json | 65 ++ tests/contract/xai_test.go | 112 +++ tests/e2e/auditlog_test.go | 8 - tests/e2e/auth_test.go | 121 +-- tests/e2e/mock_provider.go | 54 -- tests/integration/.cache/models.json | 24 + tests/integration/auditlog_test.go | 275 +++++++ tests/integration/dbassert/auditlog.go | 223 ++++++ tests/integration/dbassert/doc.go | 4 + tests/integration/dbassert/fields.go | 219 ++++++ tests/integration/dbassert/usage.go | 235 ++++++ tests/integration/doc.go | 5 + tests/integration/helpers_test.go | 161 ++++ tests/integration/main_test.go | 214 ++++++ tests/integration/setup_test.go | 412 ++++++++++ tests/integration/usage_test.go | 253 ++++++ 61 files changed, 6950 insertions(+), 165 deletions(-) create mode 100644 cmd/recordapi/main.go create mode 100644 docs/FUTURE_ARCHITECTURE.md create mode 100644 docs/TESTING_STRATEGY.md create mode 100644 tests/contract/README.md create mode 100644 tests/contract/anthropic_test.go create mode 100644 tests/contract/doc.go create mode 100644 tests/contract/gemini_test.go create mode 100644 tests/contract/groq_test.go create mode 100644 tests/contract/main_test.go create mode 100644 tests/contract/openai_test.go create mode 100644 tests/contract/testdata/anthropic/messages.json create mode 100644 tests/contract/testdata/anthropic/messages_extended_thinking.json create mode 100644 tests/contract/testdata/anthropic/messages_multi_turn.json create mode 100644 tests/contract/testdata/anthropic/messages_multimodal.json create mode 100644 tests/contract/testdata/anthropic/messages_stream.txt create mode 100644 tests/contract/testdata/anthropic/messages_with_params.json create mode 100644 tests/contract/testdata/anthropic/messages_with_tools.json create mode 100644 tests/contract/testdata/gemini/chat_completion.json create mode 100644 tests/contract/testdata/gemini/chat_completion_stream.txt create mode 100644 tests/contract/testdata/gemini/chat_with_params.json create mode 100644 tests/contract/testdata/gemini/chat_with_tools.json create mode 100644 tests/contract/testdata/gemini/models.json create mode 100644 tests/contract/testdata/groq/chat_completion.json create mode 100644 tests/contract/testdata/groq/chat_completion_stream.txt create mode 100644 tests/contract/testdata/groq/chat_with_params.json create mode 100644 tests/contract/testdata/groq/chat_with_tools.json create mode 100644 tests/contract/testdata/groq/models.json create mode 100644 tests/contract/testdata/openai/chat_completion.json create mode 100644 tests/contract/testdata/openai/chat_completion_reasoning.json create mode 100644 tests/contract/testdata/openai/chat_completion_stream.txt create mode 100644 tests/contract/testdata/openai/chat_json_mode.json create mode 100644 tests/contract/testdata/openai/chat_multi_turn.json create mode 100644 tests/contract/testdata/openai/chat_multimodal.json create mode 100644 tests/contract/testdata/openai/chat_with_params.json create mode 100644 tests/contract/testdata/openai/chat_with_tools.json create mode 100644 tests/contract/testdata/openai/models.json create mode 100644 tests/contract/testdata/xai/chat_completion.json create mode 100644 tests/contract/testdata/xai/chat_completion_stream.txt create mode 100644 tests/contract/testdata/xai/chat_with_params.json create mode 100644 tests/contract/testdata/xai/models.json create mode 100644 tests/contract/xai_test.go create mode 100644 tests/integration/.cache/models.json create mode 100644 tests/integration/auditlog_test.go create mode 100644 tests/integration/dbassert/auditlog.go create mode 100644 tests/integration/dbassert/doc.go create mode 100644 tests/integration/dbassert/fields.go create mode 100644 tests/integration/dbassert/usage.go create mode 100644 tests/integration/doc.go create mode 100644 tests/integration/helpers_test.go create mode 100644 tests/integration/main_test.go create mode 100644 tests/integration/setup_test.go create mode 100644 tests/integration/usage_test.go diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 977c1a32..c3a7a075 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -14,7 +14,10 @@ "Bash(grep:*)", "WebFetch(domain:docs.github.com)", "WebFetch(domain:docs.docker.com)", - "WebFetch(domain:docs.anthropic.com)" + "WebFetch(domain:docs.anthropic.com)", + "mcp__linear-server__list_projects", + "mcp__linear-server__list_issues", + "mcp__linear-server__get_issue" ], "deny": [], "ask": [] diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 659c2edc..3b7e1129 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -53,10 +53,26 @@ jobs: fail_ci_if_error: false continue-on-error: true + integration: + name: Integration Tests + runs-on: ubuntu-latest + needs: [test] + steps: + - uses: actions/checkout@v5 + + - name: Set up Go + uses: actions/setup-go@v6 + with: + go-version: ${{ env.GO_VERSION }} + cache: true + + - name: Run integration tests + run: go test -v -tags=integration -timeout=10m ./tests/integration/... + build: name: Build runs-on: ubuntu-latest - needs: [lint, test] + needs: [lint, test, integration] steps: - uses: actions/checkout@v5 diff --git a/.gitignore b/.gitignore index ecd34744..cb1156b8 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ # Compiled binary /gomodel +/recordapi /bin # Environment variables diff --git a/Makefile b/Makefile index 172bf67c..691ef49b 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: build run clean tidy test test-unit test-e2e lint lint-fix +.PHONY: build run clean tidy test test-e2e test-integration test-contract test-all lint lint-fix record-api # Get version info VERSION ?= $(shell git describe --tags --always --dirty) @@ -32,13 +32,34 @@ test: test-e2e: go test -v -tags=e2e ./tests/e2e/... -# Run all tests including e2e -test-all: test test-e2e +# Run integration tests (requires Docker for testcontainers) +test-integration: + go test -v -tags=integration -timeout=10m ./tests/integration/... + +# Run contract tests (validates API response structures against golden files) +test-contract: + go test -v -tags=contract -timeout=5m ./tests/contract/... + +# Run all tests including e2e, integration, and contract tests +test-all: test test-e2e test-integration test-contract + +# Record API responses for contract tests +# Usage: OPENAI_API_KEY=sk-xxx make record-api +record-api: + @echo "Recording OpenAI chat completion..." + go run ./cmd/recordapi -provider=openai -endpoint=chat \ + -output=tests/contract/testdata/openai/chat_completion.json + @echo "Recording OpenAI models..." + go run ./cmd/recordapi -provider=openai -endpoint=models \ + -output=tests/contract/testdata/openai/models.json + @echo "Done! Golden files saved to tests/contract/testdata/" # Run linter lint: golangci-lint run ./... golangci-lint run --build-tags=e2e ./tests/e2e/... + golangci-lint run --build-tags=integration ./tests/integration/... + golangci-lint run --build-tags=contract ./tests/contract/... # Run linter with auto-fix lint-fix: diff --git a/cmd/recordapi/main.go b/cmd/recordapi/main.go new file mode 100644 index 00000000..57e05874 --- /dev/null +++ b/cmd/recordapi/main.go @@ -0,0 +1,275 @@ +// Package main provides a CLI tool to record real API responses for contract tests. +// Usage: +// +// OPENAI_API_KEY=sk-xxx go run ./cmd/recordapi \ +// -provider=openai \ +// -endpoint=chat \ +// -output=tests/contract/testdata/openai/chat_completion.json +package main + +import ( + "bytes" + "encoding/json" + "flag" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strings" + "time" +) + +// Provider configurations +var providerConfigs = map[string]struct { + baseURL string + envKey string + authHeader string + contentType string +}{ + "openai": { + baseURL: "https://api.openai.com", + envKey: "OPENAI_API_KEY", + authHeader: "Authorization", + contentType: "application/json", + }, + "anthropic": { + baseURL: "https://api.anthropic.com", + envKey: "ANTHROPIC_API_KEY", + authHeader: "x-api-key", + contentType: "application/json", + }, + "gemini": { + baseURL: "https://generativelanguage.googleapis.com/v1beta/openai", + envKey: "GEMINI_API_KEY", + authHeader: "Authorization", + contentType: "application/json", + }, + "groq": { + baseURL: "https://api.groq.com/openai", + envKey: "GROQ_API_KEY", + authHeader: "Authorization", + contentType: "application/json", + }, + "xai": { + baseURL: "https://api.x.ai", + envKey: "XAI_API_KEY", + authHeader: "Authorization", + contentType: "application/json", + }, +} + +// Endpoint configurations +var endpointConfigs = map[string]struct { + path string + method string + requestBody map[string]interface{} +}{ + "chat": { + path: "/v1/chat/completions", + method: http.MethodPost, + requestBody: map[string]interface{}{ + "model": "gpt-4o-mini", + "messages": []map[string]string{ + {"role": "user", "content": "Say 'Hello, World!' and nothing else."}, + }, + "max_tokens": 50, + }, + }, + "chat_stream": { + path: "/v1/chat/completions", + method: http.MethodPost, + requestBody: map[string]interface{}{ + "model": "gpt-4o-mini", + "messages": []map[string]string{ + {"role": "user", "content": "Say 'Hello, World!' and nothing else."}, + }, + "max_tokens": 50, + "stream": true, + }, + }, + "models": { + path: "/v1/models", + method: http.MethodGet, + }, +} + +func main() { + provider := flag.String("provider", "openai", "Provider to test (openai, anthropic, gemini, groq, xai)") + endpoint := flag.String("endpoint", "chat", "Endpoint to test (chat, chat_stream, models)") + output := flag.String("output", "", "Output file path (required)") + model := flag.String("model", "", "Override model in request") + flag.Parse() + + if *output == "" { + fmt.Fprintln(os.Stderr, "Error: -output flag is required") + flag.Usage() + os.Exit(1) + } + + pConfig, ok := providerConfigs[*provider] + if !ok { + fmt.Fprintf(os.Stderr, "Error: unknown provider %q\n", *provider) + os.Exit(1) + } + + eConfig, ok := endpointConfigs[*endpoint] + if !ok { + fmt.Fprintf(os.Stderr, "Error: unknown endpoint %q\n", *endpoint) + os.Exit(1) + } + + apiKey := os.Getenv(pConfig.envKey) + if apiKey == "" { + fmt.Fprintf(os.Stderr, "Error: %s environment variable is required\n", pConfig.envKey) + os.Exit(1) + } + + // Build request body + var bodyReader io.Reader + if eConfig.requestBody != nil { + reqBody := eConfig.requestBody + + // Override model if specified + if *model != "" { + reqBody["model"] = *model + } + + // Adjust request for different providers + if *provider == "anthropic" { + reqBody = adjustForAnthropic(reqBody) + } + + bodyBytes, err := json.Marshal(reqBody) + if err != nil { + fmt.Fprintf(os.Stderr, "Error marshaling request body: %v\n", err) + os.Exit(1) + } + bodyReader = bytes.NewReader(bodyBytes) + } + + // Build URL + url := pConfig.baseURL + eConfig.path + + // Create request + req, err := http.NewRequest(eConfig.method, url, bodyReader) + if err != nil { + fmt.Fprintf(os.Stderr, "Error creating request: %v\n", err) + os.Exit(1) + } + + req.Header.Set("Content-Type", pConfig.contentType) + + // Add auth header (except for Gemini which uses query param) + if pConfig.authHeader != "" { + if pConfig.authHeader == "Authorization" { + req.Header.Set(pConfig.authHeader, "Bearer "+apiKey) + } else { + req.Header.Set(pConfig.authHeader, apiKey) + } + } + + // Add Anthropic-specific headers + if *provider == "anthropic" { + req.Header.Set("anthropic-version", "2023-06-01") + } + + // Send request + client := &http.Client{Timeout: 60 * time.Second} + fmt.Printf("Sending request to %s %s...\n", eConfig.method, url) + + resp, err := client.Do(req) + if err != nil { + fmt.Fprintf(os.Stderr, "Error sending request: %v\n", err) + os.Exit(1) + } + defer resp.Body.Close() + + fmt.Printf("Response status: %d %s\n", resp.StatusCode, resp.Status) + + // Read response body + body, err := io.ReadAll(resp.Body) + if err != nil { + fmt.Fprintf(os.Stderr, "Error reading response: %v\n", err) + os.Exit(1) + } + + // Handle streaming responses differently + if strings.HasSuffix(*endpoint, "_stream") { + if err := writeStreamOutput(*output, body); err != nil { + fmt.Fprintf(os.Stderr, "Error writing output: %v\n", err) + os.Exit(1) + } + fmt.Printf("Streaming response saved to %s\n", *output) + return + } + + // Pretty print JSON + var prettyJSON bytes.Buffer + if err := json.Indent(&prettyJSON, body, "", " "); err != nil { + // If it's not valid JSON, write raw + if err := writeOutput(*output, body); err != nil { + fmt.Fprintf(os.Stderr, "Error writing output: %v\n", err) + os.Exit(1) + } + fmt.Printf("Raw response saved to %s\n", *output) + return + } + + if err := writeOutput(*output, prettyJSON.Bytes()); err != nil { + fmt.Fprintf(os.Stderr, "Error writing output: %v\n", err) + os.Exit(1) + } + + fmt.Printf("Response saved to %s\n", *output) + + // Print response summary + var respMap map[string]interface{} + if err := json.Unmarshal(body, &respMap); err == nil { + if id, ok := respMap["id"].(string); ok { + fmt.Printf("Response ID: %s\n", id) + } + if model, ok := respMap["model"].(string); ok { + fmt.Printf("Model: %s\n", model) + } + } +} + +// adjustForAnthropic converts OpenAI-style request to Anthropic format +func adjustForAnthropic(req map[string]interface{}) map[string]interface{} { + result := make(map[string]interface{}) + + // Copy model + if model, ok := req["model"].(string); ok { + result["model"] = model + } + + // Convert max_tokens + if maxTokens, ok := req["max_tokens"].(int); ok { + result["max_tokens"] = maxTokens + } else { + result["max_tokens"] = 1024 // Default for Anthropic + } + + // Convert messages + if messages, ok := req["messages"].([]map[string]string); ok { + result["messages"] = messages + } + + return result +} + +// writeOutput writes data to the output file, creating directories as needed. +func writeOutput(path string, data []byte) error { + dir := filepath.Dir(path) + if err := os.MkdirAll(dir, 0755); err != nil { + return fmt.Errorf("failed to create directory: %w", err) + } + return os.WriteFile(path, data, 0644) +} + +// writeStreamOutput writes streaming response data to a text file. +func writeStreamOutput(path string, data []byte) error { + // For streaming responses, save as-is (SSE format) + return writeOutput(path, data) +} diff --git a/docs/FUTURE_ARCHITECTURE.md b/docs/FUTURE_ARCHITECTURE.md new file mode 100644 index 00000000..4b0d6253 --- /dev/null +++ b/docs/FUTURE_ARCHITECTURE.md @@ -0,0 +1,123 @@ +``` +┌─────────────────────────────────────────────────────────────────────────────────────────┐ +│ INGRESS LAYER │ +├─────────────────────────────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ +│ │ /v1/* │ │ /p/{prov} │ │ /admin │ │ /health │ │ /metrics │ │ +│ │ OpenAI API │ │ Pass-Through│ │ Admin API │ │ Liveness │ │ Prometheus │ │ +│ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ └─────────────┘ └─────────────┘ │ +│ │ │ │ │ +└──────────┼────────────────┼────────────────┼────────────────────────────────────────────┘ + │ │ │ + ▼ ▼ ▼ +┌─────────────────────────────────────────────────────────────────────────────────────────┐ +│ AUTHENTICATION LAYER │ +├─────────────────────────────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐ │ +│ │ API Key Auth │ │ Session Auth │ │ SSO/OIDC │ │ +│ │ (Bearer token) │ │ (Cookie/JWT) │ │ (OAuth 2.0) │ │ +│ │ gm_live_xxx │ │ /admin/* │ │ /auth/sso/* │ │ +│ └────────┬─────────┘ └────────┬─────────┘ └────────┬─────────┘ │ +│ │ │ │ │ +│ └─────────────────────┴─────────────────────┘ │ +│ │ │ +│ ▼ │ +│ ┌────────────────────────┐ │ +│ │ Identity Resolution │ │ +│ │ Key→User→Team→Org │ │ +│ └────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────────────────────────────┐ +│ POLICY ENGINE │ +├─────────────────────────────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ +│ │ Rate Limit │ │ Budget │ │ Model │ │ Guardrail │ │ +│ │ Check │ │ Check │ │ Allowlist │ │ Selection │ │ +│ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ │ +│ │ │ │ │ │ +│ └────────────────┴────────────────┴────────────────┘ │ +│ │ │ +│ ┌─────────────┴─────────────┐ │ +│ │ Policy Decision │ │ +│ │ ALLOW / DENY / WARN │ │ +│ └───────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────────────────────────────┐ +│ GUARDRAILS PIPELINE │ +├─────────────────────────────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌─────────────────────────────────────────────────────────────────────────────────┐ │ +│ │ PRE-PROCESSING (Input) │ │ +│ │ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ │ +│ │ │ Sys. Prompt │ │ PII Detect │ │ Injection │ │ Custom Rules │ │ │ +│ │ │ Decorator │ │ & Anonymize │ │ Detection │ │ Provider & │ │ │ +│ │ │ │ │ (Ollama) │ │ │ │Model Specific│ │ │ +│ │ └──────────────┘ └──────────────┘ └──────────────┘ └──────────────┘ │ │ +│ └─────────────────────────────────────────────────────────────────────────────────┘ │ +│ │ │ +│ ▼ │ +│ ┌─────────────────────────────────────────────────────────────────────────────────┐ │ +│ │ POST-PROCESSING (Output) │ │ +│ │ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ │ +│ │ │ Content │ │ JSON │ │ PII Fill │ │ Custom │ │ │ +│ │ │ Moderation │ │ Schema │ │ (Output) │ │ Sanitizer │ │ │ +│ │ └──────────────┘ └──────────────┘ └──────────────┘ └──────────────┘ │ │ +│ └─────────────────────────────────────────────────────────────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────────────────────────────┐ +│ ROUTING LAYER │ +├─────────────────────────────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌────────────────────┐ ┌────────────────────┐ │ +│ │ Model Registry │◀────────▶│ Failover Config │ │ +│ │ model → provider │ │ gpt-5 → [gemini] │ │ +│ └─────────┬──────────┘ └────────────────────┘ │ +│ │ │ +│ ▼ │ +│ ┌─────────────────────────────────────────────────────────────────────────────────┐ │ +│ │ Provider Router │ │ +│ │ │ │ +│ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ │ +│ │ │ OpenAI │ │Anthropic │ │ Gemini │ │ Groq │ │ xAI │ │ Ollama │ │ │ +│ │ └──────────┘ └──────────┘ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │ │ +│ │ │ │ +│ └─────────────────────────────────────────────────────────────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────────────────────────────┐ +│ DATA LAYER │ +├─────────────────────────────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌─────────────────────────────────────────────────────────────────────────────────┐ │ +│ │ Storage Abstraction │ │ +│ │ │ │ +│ │ Mode: SINGLE_INSTANCE Mode: MULTI_INSTANCE │ │ +│ │ ┌──────────────────────┐ ┌──────────────────────┐ │ │ +│ │ │ YAML + SQLite + │ │ PostgreSQL + │ │ │ +│ │ │ In-Memory Cache │ │ Redis + MongoDB │ │ │ +│ │ └──────────────────────┘ └──────────────────────┘ │ │ +│ │ │ │ +│ └─────────────────────────────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌───────────────┐ ┌───────────────┐ ┌───────────────┐ ┌───────────────┐ │ +│ │ Config │ │ Identity │ │ Usage │ │ Audit Logs │ │ +│ │ (providers, │ │ (keys,users │ │ (tokens,$, │ │ (who,what, │ │ +│ │ pricing) │ │ teams,orgs) │ │ requests) │ │ when) │ │ +│ └───────────────┘ └───────────────┘ └───────────────┘ └───────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────────────────────────────┘ +``` diff --git a/docs/TESTING_STRATEGY.md b/docs/TESTING_STRATEGY.md new file mode 100644 index 00000000..ed9a2338 --- /dev/null +++ b/docs/TESTING_STRATEGY.md @@ -0,0 +1,241 @@ +# GOModel Testing Strategy + +A 3-layer testing strategy with **DB state verification** as the highest priority: + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Layer 3: Contract Tests (Provider API Compatibility) │ +│ - Golden files with real API responses │ +│ - Schema validation, no API calls in CI │ +├─────────────────────────────────────────────────────────────┤ +│ Layer 2: Integration Tests (DB Verification) ← PRIORITY │ +│ - Real PostgreSQL/MongoDB via testcontainers │ +│ - Verify DB state after each request │ +│ - Field completeness assertions │ +├─────────────────────────────────────────────────────────────┤ +│ Layer 1: Unit + E2E Tests (Current - unchanged) │ +└─────────────────────────────────────────────────────────────┘ +``` + +## Test Architecture Overview + +``` +tests/ +├── e2e/ # End-to-end tests (in-process mock server) +│ ├── *_test.go # E2E test files (requires -tags=e2e) +│ └── mock_provider.go # Mock provider for testing +├── contract/ # Contract tests (golden file validation) +│ ├── *_test.go # Contract test files (requires -tags=contract) +│ ├── main_test.go # Shared helpers and types +│ ├── README.md # Documentation and curl examples +│ └── testdata/ # Golden files from real API responses +│ ├── openai/ +│ ├── anthropic/ +│ ├── gemini/ +│ ├── xai/ +│ └── groq/ +└── integration/ # Integration tests (DB verification) + └── *_test.go # DB state verification tests + +internal/ +├── providers/ +│ └── *_test.go # Unit tests alongside implementation +├── cache/ +│ └── *_test.go +└── ... +``` + +## Layer 1: Unit + E2E Tests + +### Unit Tests + +Located alongside implementation files (`*_test.go`). Test individual components in isolation. + +```bash +# Run unit tests only +make test + +# Run specific package tests +go test ./internal/providers -v -run TestName +``` + +### E2E Tests + +In-process mock server tests. No Docker required. + +```bash +# Run E2E tests +make test-e2e + +# Run specific E2E test +go test -v -tags=e2e ./tests/e2e/... -run TestName +``` + +**Key characteristics:** +- Tests full request flow through the gateway +- Uses mock providers (no real API calls) +- Validates routing, transformation, and response handling +- Fast execution, suitable for CI + +## Layer 2: Integration Tests (DB Verification) + +Real database testing with testcontainers. **Priority for data integrity.** + +```bash +# Run integration tests (requires Docker) +go test -v -tags=integration ./tests/integration/... +``` + +**Key characteristics:** +- Real PostgreSQL/MongoDB via testcontainers +- Verify DB state after each request +- Field completeness assertions +- Validates audit logging, usage tracking + +**Focus areas:** +- Audit log entries are complete and accurate +- Usage metrics are properly recorded +- Request/response pairs are correctly stored +- Token counts match expected values + +## Layer 3: Contract Tests (Provider API Compatibility) + +Golden file tests validating API response structures. No API calls in CI. + +```bash +# Run contract tests +go test -v -tags=contract ./tests/contract/... + +# Run specific provider tests +go test -v -tags=contract ./tests/contract/... -run TestOpenAI +``` + +**Key characteristics:** +- Golden files contain real API responses (recorded manually) +- Tests validate response structure, not content +- No network calls during test execution +- Detects API contract changes + +### Supported Providers + +| Provider | Endpoint | Features Tested | +|----------|----------|-----------------| +| OpenAI | api.openai.com | Chat, streaming, models, tools, JSON mode, multimodal, reasoning | +| Anthropic | api.anthropic.com | Messages, streaming, tools, extended thinking, multimodal | +| Gemini | generativelanguage.googleapis.com | Chat, streaming, models, tools (OpenAI-compatible) | +| xAI | api.x.ai | Chat, streaming, models (OpenAI-compatible) | +| Groq | api.groq.com | Chat, streaming, models, tools (OpenAI-compatible) | + +### Golden File Structure + +``` +tests/contract/testdata/ +├── openai/ +│ ├── chat_completion.json # Basic chat +│ ├── chat_completion_reasoning.json # o3-mini reasoning +│ ├── chat_completion_stream.txt # SSE streaming +│ ├── chat_with_tools.json # Function calling +│ ├── chat_json_mode.json # Structured output +│ ├── chat_with_params.json # Temperature, stop sequences +│ ├── chat_multi_turn.json # Conversation history +│ ├── chat_multimodal.json # Image input +│ └── models.json # Models list +├── anthropic/ +│ ├── messages.json # Basic messages +│ ├── messages_stream.txt # SSE streaming +│ ├── messages_with_params.json # System, temperature +│ ├── messages_with_tools.json # Tool use +│ ├── messages_extended_thinking.json # Reasoning +│ ├── messages_multi_turn.json # Conversation +│ └── messages_multimodal.json # Image input +├── gemini/ # 5 files +├── xai/ # 4 files +└── groq/ # 5 files +``` + +### Recording New Golden Files + +Use the `recordapi` tool or manual curl commands: + +```bash +# Using recordapi +go run ./cmd/recordapi -provider=openai -endpoint=chat -output=tests/contract/testdata/openai/chat_completion.json + +# Manual curl (see tests/contract/README.md for full examples) +curl https://api.openai.com/v1/chat/completions \ + -H "Authorization: Bearer $OPENAI_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{"model":"gpt-4o-mini","messages":[{"role":"user","content":"Say Hello World"}],"max_tokens":50}' \ + | jq . > tests/contract/testdata/openai/chat_completion.json +``` + +## Running All Tests + +```bash +# All tests (unit + E2E) +make test-all + +# Contract tests separately +go test -v -tags=contract ./tests/contract/... + +# Integration tests (requires Docker) +go test -v -tags=integration ./tests/integration/... + +# Full CI pipeline +make lint && make test-all +``` + +## Test Commands Reference + +| Command | Description | +|---------|-------------| +| `make test` | Unit tests only | +| `make test-e2e` | E2E tests with mock providers | +| `make test-all` | Unit + E2E tests | +| `go test -tags=contract ./tests/contract/...` | Contract tests | +| `go test -tags=integration ./tests/integration/...` | Integration tests | +| `make lint` | Run golangci-lint | + +## CI/CD Integration + +```yaml +# GitHub Actions example +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version: '1.24' + + # Unit + E2E (no external dependencies) + - run: make test-all + + # Contract tests (no API calls, uses golden files) + - run: go test -tags=contract ./tests/contract/... + + # Integration tests (requires Docker) + - run: go test -tags=integration ./tests/integration/... +``` + +## Best Practices + +1. **Unit tests**: Test business logic in isolation, mock external dependencies +2. **E2E tests**: Test full request flow with mock providers +3. **Contract tests**: Validate API compatibility, update golden files when APIs change +4. **Integration tests**: Verify DB state, use real databases via testcontainers + +### When to Update Golden Files + +- Provider releases new API version +- Response structure changes +- New fields are added to responses +- Testing new provider features + +### Contract Test Maintenance + +1. Run API calls manually or via `recordapi` tool +2. Save responses to `tests/contract/testdata/{provider}/` +3. Verify tests pass with new golden files +4. Commit golden files to version control diff --git a/go.mod b/go.mod index 9179b2e4..de76b00b 100644 --- a/go.mod +++ b/go.mod @@ -13,17 +13,37 @@ require ( github.com/redis/go-redis/v9 v9.17.2 github.com/spf13/viper v1.21.0 github.com/stretchr/testify v1.11.1 + github.com/testcontainers/testcontainers-go v0.34.0 + github.com/testcontainers/testcontainers-go/modules/mongodb v0.34.0 + github.com/testcontainers/testcontainers-go/modules/postgres v0.34.0 go.mongodb.org/mongo-driver/v2 v2.4.1 modernc.org/sqlite v1.44.2 ) require ( + dario.cat/mergo v1.0.0 // indirect + github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 // indirect + github.com/Microsoft/go-winio v0.6.2 // indirect github.com/beorn7/perks v1.0.1 // indirect + github.com/cenkalti/backoff/v4 v4.2.1 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/containerd/containerd v1.7.18 // indirect + github.com/containerd/log v0.1.0 // indirect + github.com/containerd/platforms v0.2.1 // indirect + github.com/cpuguy83/dockercfg v0.3.2 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect + github.com/distribution/reference v0.6.0 // indirect + github.com/docker/docker v27.1.1+incompatible // indirect + github.com/docker/go-connections v0.5.0 // indirect + github.com/docker/go-units v0.5.0 // indirect github.com/dustin/go-humanize v1.0.1 // indirect + github.com/felixge/httpsnoop v1.0.4 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect + github.com/go-logr/logr v1.4.1 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-ole/go-ole v1.2.6 // indirect + github.com/gogo/protobuf v1.3.2 // indirect github.com/golang/snappy v1.0.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect @@ -31,27 +51,49 @@ require ( github.com/klauspost/compress v1.18.0 // indirect github.com/kylelemons/godebug v1.1.0 // indirect github.com/labstack/gommon v0.4.2 // indirect + github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect + github.com/magiconair/properties v1.8.7 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect + github.com/moby/docker-image-spec v1.3.1 // indirect + github.com/moby/patternmatcher v0.6.0 // indirect + github.com/moby/sys/sequential v0.5.0 // indirect + github.com/moby/sys/user v0.1.0 // indirect + github.com/moby/term v0.5.0 // indirect + github.com/morikuni/aec v1.0.0 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect + github.com/opencontainers/go-digest v1.0.0 // indirect + github.com/opencontainers/image-spec v1.1.0 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/common v0.67.4 // indirect github.com/prometheus/procfs v0.19.2 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/sagikazarmark/locafero v0.12.0 // indirect + github.com/shirou/gopsutil/v3 v3.23.12 // indirect + github.com/shoenig/go-m1cpu v0.1.6 // indirect + github.com/sirupsen/logrus v1.9.3 // indirect github.com/spf13/afero v1.15.0 // indirect github.com/spf13/cast v1.10.0 // indirect github.com/spf13/pflag v1.0.10 // indirect github.com/subosito/gotenv v1.6.0 // indirect + github.com/tklauser/go-sysconf v0.3.12 // indirect + github.com/tklauser/numcpus v0.6.1 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/fasttemplate v1.2.2 // indirect github.com/xdg-go/pbkdf2 v1.0.0 // indirect github.com/xdg-go/scram v1.1.2 // indirect github.com/xdg-go/stringprep v1.0.4 // indirect github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect + github.com/yusufpapurcu/wmi v1.2.3 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 // indirect + go.opentelemetry.io/otel v1.24.0 // indirect + go.opentelemetry.io/otel/metric v1.24.0 // indirect + go.opentelemetry.io/otel/trace v1.24.0 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/crypto v0.46.0 // indirect diff --git a/go.sum b/go.sum index eceac8d7..155843d2 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,11 @@ +dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk= +dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= +github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24 h1:bvDV9vkmnHYOMsOr4WLk+Vo07yKIzd94sVoIqshQ4bU= +github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8= +github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 h1:UQHMgLO+TxOElx5B5HZ4hJQsoJ/PvUvKRhJHDQXO8P8= +github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ= github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= @@ -6,29 +14,65 @@ github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= +github.com/cenkalti/backoff/v4 v4.2.1 h1:y4OZtCnogmCPw98Zjyt5a6+QwPLGkiQsYW5oUqylYbM= +github.com/cenkalti/backoff/v4 v4.2.1/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/containerd/containerd v1.7.18 h1:jqjZTQNfXGoEaZdW1WwPU0RqSn1Bm2Ay/KJPUuO8nao= +github.com/containerd/containerd v1.7.18/go.mod h1:IYEk9/IO6wAPUz2bCMVUbsfXjzw5UNP5fLz4PsUygQ4= +github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= +github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= +github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A= +github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw= +github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA= +github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc= +github.com/creack/pty v1.1.18 h1:n56/Zwd5o6whRC5PMGretI4IdRLlmBXYNjScPaBgsbY= +github.com/creack/pty v1.1.18/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= +github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= +github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/docker/docker v27.1.1+incompatible h1:hO/M4MtV36kzKldqnA37IWhebRA+LnqqcqDja6kVaKY= +github.com/docker/docker v27.1.1+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c= +github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc= +github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= +github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.1 h1:pKouT5E8xu9zeFC39JXRDukb6JFQPXM5p5I91188VAQ= +github.com/go-logr/logr v1.4.1/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= +github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro= github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs= github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.16.0 h1:YBftPWNWd4WwGqtY2yeZL2ef8rHAxPBD8KFhJpmcqms= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.16.0/go.mod h1:YN5jB8ie0yfIUg6VvR9Kz84aCaG7AsGZnLjhHbUqwPg= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= @@ -41,6 +85,8 @@ github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= @@ -53,18 +99,46 @@ github.com/labstack/echo/v4 v4.15.0 h1:hoRTKWcnR5STXZFe9BmYun9AMTNeSbjHi2vtDuADJ github.com/labstack/echo/v4 v4.15.0/go.mod h1:xmw1clThob0BSVRX1CRQkGQ/vjwcpOMjQZSZa9fKA/c= github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0= github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU= +github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= +github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4= +github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= +github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= +github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= +github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= +github.com/moby/patternmatcher v0.6.0 h1:GmP9lR19aU5GqSSFko+5pRqHi+Ohk1O69aFiKkVGiPk= +github.com/moby/patternmatcher v0.6.0/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc= +github.com/moby/sys/sequential v0.5.0 h1:OPvI35Lzn9K04PBbCLW0g4LcFAJgHsvXsRyewg5lXtc= +github.com/moby/sys/sequential v0.5.0/go.mod h1:tH2cOOs5V9MlPiXcQzRC+eEyab644PWKGRYaaV5ZZlo= +github.com/moby/sys/user v0.1.0 h1:WmZ93f5Ux6het5iituh9x2zAG7NFY9Aqi49jjE1PaQg= +github.com/moby/sys/user v0.1.0/go.mod h1:fKJhFOnsCN6xZ5gSfbM6zaHGgDJMrqt9/reuj4T7MmU= +github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0= +github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y= +github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe h1:iruDEfMl2E6fbMZ9s0scYfZQ84/6SPL6zC8ACM2oIL0= +github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe/go.mod h1:wL8QJuTMNUDYhXwkmfOly8iTdp5TEcJFWZD2D7SIkUc= +github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= +github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug= +github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw= +github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= @@ -81,6 +155,14 @@ github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjR github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= github.com/sagikazarmark/locafero v0.12.0 h1:/NQhBAkUb4+fH1jivKHWusDYFjMOOKU88eegjfxfHb4= github.com/sagikazarmark/locafero v0.12.0/go.mod h1:sZh36u/YSZ918v0Io+U9ogLYQJ9tLLBmM4eneO6WwsI= +github.com/shirou/gopsutil/v3 v3.23.12 h1:z90NtUkp3bMtmICZKpC4+WaknU1eXtp5vtbQ11DgpE4= +github.com/shirou/gopsutil/v3 v3.23.12/go.mod h1:1FrWgea594Jp7qmjHUUPlJDTPgcsb9mGnXDxavtikzM= +github.com/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFtM= +github.com/shoenig/go-m1cpu v0.1.6/go.mod h1:1JJMcUBvfNwpq05QDQVAnx3gUHr9IYF7GNg9SUEw2VQ= +github.com/shoenig/test v0.6.4 h1:kVTaSd7WLz5WZ2IaoM0RSzRsUD+m8wRR+5qvntpn4LU= +github.com/shoenig/test v0.6.4/go.mod h1:byHiCGXqrVaflBLAMq/srcZIHynQPQgeyvkvXnjqq0k= +github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= @@ -90,12 +172,29 @@ github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3A github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +github.com/testcontainers/testcontainers-go v0.34.0 h1:5fbgF0vIN5u+nD3IWabQwRybuB4GY8G2HHgCkbMzMHo= +github.com/testcontainers/testcontainers-go v0.34.0/go.mod h1:6P/kMkQe8yqPHfPWNulFGdFHTD8HB2vLq/231xY2iPQ= +github.com/testcontainers/testcontainers-go/modules/mongodb v0.34.0 h1:o3bgcECyBFfMwqexCH/6vIJ8XzbCffCP/Euesu33rgY= +github.com/testcontainers/testcontainers-go/modules/mongodb v0.34.0/go.mod h1:ljLR42dN7k40CX0dp30R8BRIB3OOdvr7rBANEpfmMs4= +github.com/testcontainers/testcontainers-go/modules/postgres v0.34.0 h1:c51aBXT3v2HEBVarmaBnsKzvgZjC5amn0qsj8Naqi50= +github.com/testcontainers/testcontainers-go/modules/postgres v0.34.0/go.mod h1:EWP75ogLQU4M4L8U+20mFipjV4WIR9WtlMXSB6/wiuc= +github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU= +github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI= +github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk= +github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo= @@ -110,9 +209,31 @@ github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZ github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM= github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +github.com/yusufpapurcu/wmi v1.2.3 h1:E1ctvB7uKFMOJw3fdOW32DwGE9I7t++CRUEMKvFoFiw= +github.com/yusufpapurcu/wmi v1.2.3/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= +go.mongodb.org/mongo-driver v1.13.1 h1:YIc7HTYsKndGK4RFzJ3covLz1byri52x0IoMB0Pt/vk= +go.mongodb.org/mongo-driver v1.13.1/go.mod h1:wcDf1JBCXy2mOW0bWHwO/IOYqdca1MPCwDtFu/Z9+eo= go.mongodb.org/mongo-driver/v2 v2.4.1 h1:hGDMngUao03OVQ6sgV5csk+RWOIkF+CuLsTPobNMGNI= go.mongodb.org/mongo-driver/v2 v2.4.1/go.mod h1:jHeEDJHJq7tm6ZF45Issun9dbogjfnPySb1vXA7EeAI= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 h1:jq9TW8u3so/bN+JPT166wjOI6/vQPF6Xe7nMNIltagk= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0/go.mod h1:p8pYQP+m5XfbZm9fxtSKAbM6oIllS7s2AfxrChvc7iw= +go.opentelemetry.io/otel v1.24.0 h1:0LAOdjNmQeSTzGBzduGe/rU4tZhMwL5rWgtp9Ku5Jfo= +go.opentelemetry.io/otel v1.24.0/go.mod h1:W7b9Ozg4nkF5tWI5zsXkaKKDjdVjpD4oAt9Qi/MArHo= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.19.0 h1:Mne5On7VWdx7omSrSSZvM4Kw7cS7NQkOOmLcgscI51U= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.19.0/go.mod h1:IPtUMKL4O3tH5y+iXVyAXqpAwMuzC1IrxVS81rummfE= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0 h1:IeMeyr1aBvBiPVYihXIaeIZba6b8E1bYp7lbdxK8CQg= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0/go.mod h1:oVdCUtjq9MK9BlS7TtucsQwUcXcymNiEDjgDD2jMtZU= +go.opentelemetry.io/otel/metric v1.24.0 h1:6EhoGWWK28x1fbpA4tYTOWBkPefTDQnb8WSGXlc88kI= +go.opentelemetry.io/otel/metric v1.24.0/go.mod h1:VYhLe1rFfxuTXLgj4CBiyz+9WYBA8pNGJgDcSFRKBco= +go.opentelemetry.io/otel/sdk v1.19.0 h1:6USY6zH+L8uMH8L3t1enZPR3WFEmSTADlqldyHtJi3o= +go.opentelemetry.io/otel/sdk v1.19.0/go.mod h1:NedEbbS4w3C6zElbLdPJKOpJQOrGUJ+GfzpjUvI0v1A= +go.opentelemetry.io/otel/trace v1.24.0 h1:CsKnnL4dUAr/0llH9FKuc698G04IrpWV0MQA/Y1YELI= +go.opentelemetry.io/otel/trace v1.24.0/go.mod h1:HPc3Xr/cOApsBI154IU0OI0HJexz+aw5uPdbs3UCjNU= +go.opentelemetry.io/proto/otlp v1.0.0 h1:T0TX0tmXU8a3CbNXzEKGeU5mIVOdf0oykP+u2lIVU/I= +go.opentelemetry.io/proto/otlp v1.0.0/go.mod h1:Sy6pihPLfYHkr3NkUbEhGHFhINUSI/v80hjKIs5JXpM= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= @@ -120,33 +241,53 @@ go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU= golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0= golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY= golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk= golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q= +golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= @@ -157,10 +298,22 @@ golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ= golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/genproto v0.0.0-20230920204549-e6e6cdab5c13 h1:vlzZttNJGVqTsRFU9AmdnrcO1Znh8Ew9kCD//yjigk0= +google.golang.org/genproto/googleapis/api v0.0.0-20240318140521-94a12d6c2237 h1:RFiFrvy37/mpSpdySBDrUdipW/dHwsRwh3J3+A9VgT4= +google.golang.org/genproto/googleapis/api v0.0.0-20240318140521-94a12d6c2237/go.mod h1:Z5Iiy3jtmioajWHDGFk7CeugTyHtPvMHA4UTmUkyalE= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240318140521-94a12d6c2237 h1:NnYq6UN9ReLM9/Y01KWNOWyI5xQ9kbIms5GGJVwS/Yc= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240318140521-94a12d6c2237/go.mod h1:WtryC6hu0hhx87FDGxWCDptyssuo68sk10vYjF+T9fY= +google.golang.org/grpc v1.64.1 h1:LKtvyfbX3UGVPFcGqJ9ItpVWW6oN/2XqTxfAnwRRXiA= +google.golang.org/grpc v1.64.1/go.mod h1:hiQF4LFZelK2WKaP6W0L92zGHtiQdZxk8CrSdvyjeP0= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= @@ -169,6 +322,8 @@ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EV gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gotest.tools/v3 v3.5.1 h1:EENdUnS3pdur5nybKYIh2Vfgc8IUNBjxDPSjtiJcOzU= +gotest.tools/v3 v3.5.1/go.mod h1:isy3WKz7GK6uNw/sbHzfKBLvlvXwUyV06n6brMxxopU= modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis= modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= modernc.org/ccgo/v4 v4.30.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc= diff --git a/tests/contract/README.md b/tests/contract/README.md new file mode 100644 index 00000000..760f54b0 --- /dev/null +++ b/tests/contract/README.md @@ -0,0 +1,419 @@ +# Contract Tests + +Contract tests validate API response structures against recorded golden files. These tests verify that the gateway correctly handles provider API responses without making actual API calls during CI. + +## Golden Files Structure + +``` +testdata/ +├── openai/ +│ ├── chat_completion.json # Basic chat completion +│ ├── chat_completion_reasoning.json # o3-mini reasoning model +│ ├── chat_completion_stream.txt # SSE streaming format +│ ├── chat_with_tools.json # Function calling +│ ├── chat_json_mode.json # Structured JSON output +│ ├── chat_with_params.json # System prompt, temperature, stop sequences +│ ├── chat_multi_turn.json # Multi-turn conversation +│ ├── chat_multimodal.json # Image input +│ └── models.json # Models list +├── anthropic/ +│ ├── messages.json # Basic messages +│ ├── messages_stream.txt # SSE streaming format +│ ├── messages_with_params.json # System prompt, temperature, stop sequences +│ ├── messages_with_tools.json # Tool use +│ ├── messages_extended_thinking.json # Extended thinking (reasoning) +│ ├── messages_multi_turn.json # Multi-turn conversation +│ └── messages_multimodal.json # Image input +├── gemini/ +│ ├── chat_completion.json # Basic chat (OpenAI-compatible) +│ ├── chat_completion_stream.txt # SSE streaming format +│ ├── chat_with_params.json # System prompt, temperature +│ ├── chat_with_tools.json # Function calling +│ └── models.json # Models list +├── xai/ +│ ├── chat_completion.json # Basic chat (OpenAI-compatible) +│ ├── chat_completion_stream.txt # SSE streaming format +│ ├── chat_with_params.json # System prompt, temperature +│ └── models.json # Models list +└── groq/ + ├── chat_completion.json # Basic chat (OpenAI-compatible) + ├── chat_completion_stream.txt # SSE streaming format + ├── chat_with_params.json # System prompt, temperature + ├── chat_with_tools.json # Function calling + └── models.json # Models list +``` + +## Running Contract Tests + +```bash +# Run all contract tests +go test -v -tags=contract ./tests/contract/... + +# Run specific provider tests +go test -v -tags=contract ./tests/contract/... -run TestOpenAI +go test -v -tags=contract ./tests/contract/... -run TestAnthropic +go test -v -tags=contract ./tests/contract/... -run TestGemini +go test -v -tags=contract ./tests/contract/... -run TestXAI +go test -v -tags=contract ./tests/contract/... -run TestGroq +``` + +## Recording Golden Files + +Use the `recordapi` tool or curl commands below to record fresh golden files. + +### Using recordapi Tool + +```bash +# OpenAI +go run ./cmd/recordapi -provider=openai -endpoint=chat -output=tests/contract/testdata/openai/chat_completion.json +go run ./cmd/recordapi -provider=openai -endpoint=chat_stream -output=tests/contract/testdata/openai/chat_completion_stream.txt +go run ./cmd/recordapi -provider=openai -endpoint=models -output=tests/contract/testdata/openai/models.json + +# Anthropic +go run ./cmd/recordapi -provider=anthropic -endpoint=chat -model=claude-sonnet-4-20250514 -output=tests/contract/testdata/anthropic/messages.json + +# Gemini +go run ./cmd/recordapi -provider=gemini -endpoint=chat -model=gemini-2.5-flash-preview-09-2025 -output=tests/contract/testdata/gemini/chat_completion.json + +# Groq +go run ./cmd/recordapi -provider=groq -endpoint=chat -model=llama-3.3-70b-versatile -output=tests/contract/testdata/groq/chat_completion.json + +# xAI +go run ./cmd/recordapi -provider=xai -endpoint=chat -model=grok-3-mini -output=tests/contract/testdata/xai/chat_completion.json +``` + +### Curl Commands Reference + +#### OpenAI + +```bash +# Basic chat completion +curl https://api.openai.com/v1/chat/completions \ + -H "Authorization: Bearer $OPENAI_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{"model":"gpt-4o-mini","messages":[{"role":"user","content":"Say Hello World"}],"max_tokens":50}' + +# Streaming +curl https://api.openai.com/v1/chat/completions \ + -H "Authorization: Bearer $OPENAI_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{"model":"gpt-4o-mini","messages":[{"role":"user","content":"Say Hello World"}],"max_tokens":50,"stream":true}' + +# Models list +curl https://api.openai.com/v1/models \ + -H "Authorization: Bearer $OPENAI_API_KEY" + +# Reasoning model (o3-mini) +curl https://api.openai.com/v1/chat/completions \ + -H "Authorization: Bearer $OPENAI_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{"model":"o3-mini","messages":[{"role":"user","content":"What is 2+2?"}],"max_completion_tokens":100}' + +# Function calling +curl https://api.openai.com/v1/chat/completions \ + -H "Authorization: Bearer $OPENAI_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{ + "model":"gpt-4o-mini", + "messages":[{"role":"user","content":"What is the weather in Paris?"}], + "tools":[{"type":"function","function":{"name":"get_weather","description":"Get weather","parameters":{"type":"object","properties":{"location":{"type":"string"}}}}}], + "tool_choice":"auto" + }' + +# JSON mode +curl https://api.openai.com/v1/chat/completions \ + -H "Authorization: Bearer $OPENAI_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{ + "model":"gpt-4o-mini", + "messages":[{"role":"user","content":"List 3 colors as JSON array"}], + "response_format":{"type":"json_object"} + }' + +# With parameters (system prompt, temperature, stop sequences) +curl https://api.openai.com/v1/chat/completions \ + -H "Authorization: Bearer $OPENAI_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{ + "model":"gpt-4o-mini", + "messages":[ + {"role":"system","content":"You are a helpful assistant. Be concise."}, + {"role":"user","content":"Count from 1 to 10"} + ], + "temperature":0.7, + "top_p":0.9, + "max_tokens":100, + "stop":["5","five"] + }' + +# Multi-turn conversation +curl https://api.openai.com/v1/chat/completions \ + -H "Authorization: Bearer $OPENAI_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{ + "model":"gpt-4o-mini", + "messages":[ + {"role":"system","content":"You are a math tutor."}, + {"role":"user","content":"What is 5+3?"}, + {"role":"assistant","content":"5+3 equals 8."}, + {"role":"user","content":"And if I add 2 more?"} + ], + "max_tokens":50 + }' + +# Multimodal (image input) +curl https://api.openai.com/v1/chat/completions \ + -H "Authorization: Bearer $OPENAI_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{ + "model":"gpt-4o-mini", + "messages":[{ + "role":"user", + "content":[ + {"type":"text","text":"What colors do you see in this image?"}, + {"type":"image_url","image_url":{"url":"https://www.google.com/images/branding/googlelogo/2x/googlelogo_color_272x92dp.png"}} + ] + }], + "max_tokens":100 + }' +``` + +#### Anthropic + +```bash +# Basic messages +curl https://api.anthropic.com/v1/messages \ + -H "x-api-key: $ANTHROPIC_API_KEY" \ + -H "anthropic-version: 2023-06-01" \ + -H "Content-Type: application/json" \ + -d '{"model":"claude-sonnet-4-20250514","max_tokens":50,"messages":[{"role":"user","content":"Say Hello World"}]}' + +# Streaming +curl https://api.anthropic.com/v1/messages \ + -H "x-api-key: $ANTHROPIC_API_KEY" \ + -H "anthropic-version: 2023-06-01" \ + -H "Content-Type: application/json" \ + -d '{"model":"claude-sonnet-4-20250514","max_tokens":50,"stream":true,"messages":[{"role":"user","content":"Say Hello World"}]}' + +# With parameters (system prompt, temperature, stop sequences) +curl https://api.anthropic.com/v1/messages \ + -H "x-api-key: $ANTHROPIC_API_KEY" \ + -H "anthropic-version: 2023-06-01" \ + -H "Content-Type: application/json" \ + -d '{ + "model":"claude-sonnet-4-20250514", + "system":"You are a helpful assistant. Be concise.", + "messages":[{"role":"user","content":"Count from 1 to 5"}], + "max_tokens":100, + "temperature":0.7, + "top_p":0.9, + "stop_sequences":["3"] + }' + +# Tool use +curl https://api.anthropic.com/v1/messages \ + -H "x-api-key: $ANTHROPIC_API_KEY" \ + -H "anthropic-version: 2023-06-01" \ + -H "Content-Type: application/json" \ + -d '{ + "model":"claude-sonnet-4-20250514", + "messages":[{"role":"user","content":"What is the weather in Paris?"}], + "tools":[{"name":"get_weather","description":"Get weather for a location","input_schema":{"type":"object","properties":{"location":{"type":"string"}},"required":["location"]}}], + "max_tokens":200 + }' + +# Extended thinking (reasoning) +curl https://api.anthropic.com/v1/messages \ + -H "x-api-key: $ANTHROPIC_API_KEY" \ + -H "anthropic-version: 2023-06-01" \ + -H "Content-Type: application/json" \ + -d '{ + "model":"claude-sonnet-4-20250514", + "messages":[{"role":"user","content":"Solve: If x + 5 = 12, what is x?"}], + "max_tokens":2000, + "thinking":{"type":"enabled","budget_tokens":1024} + }' + +# Multi-turn conversation +curl https://api.anthropic.com/v1/messages \ + -H "x-api-key: $ANTHROPIC_API_KEY" \ + -H "anthropic-version: 2023-06-01" \ + -H "Content-Type: application/json" \ + -d '{ + "model":"claude-sonnet-4-20250514", + "messages":[ + {"role":"user","content":"What is 5+3?"}, + {"role":"assistant","content":"8"}, + {"role":"user","content":"Multiply that by 2"} + ], + "max_tokens":50 + }' + +# Multimodal (image input) +curl https://api.anthropic.com/v1/messages \ + -H "x-api-key: $ANTHROPIC_API_KEY" \ + -H "anthropic-version: 2023-06-01" \ + -H "Content-Type: application/json" \ + -d '{ + "model":"claude-sonnet-4-20250514", + "messages":[{ + "role":"user", + "content":[ + {"type":"text","text":"What colors do you see in this logo?"}, + {"type":"image","source":{"type":"url","url":"https://www.google.com/images/branding/googlelogo/2x/googlelogo_color_272x92dp.png"}} + ] + }], + "max_tokens":100 + }' +``` + +#### Gemini (OpenAI-compatible endpoint) + +```bash +# Basic chat completion +curl "https://generativelanguage.googleapis.com/v1beta/openai/chat/completions" \ + -H "Authorization: Bearer $GEMINI_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{"model":"gemini-2.5-flash-preview-09-2025","messages":[{"role":"user","content":"Say Hello World"}],"max_tokens":50}' + +# Streaming +curl "https://generativelanguage.googleapis.com/v1beta/openai/chat/completions" \ + -H "Authorization: Bearer $GEMINI_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{"model":"gemini-2.5-flash-preview-09-2025","messages":[{"role":"user","content":"Say Hello World"}],"max_tokens":50,"stream":true}' + +# Models list +curl "https://generativelanguage.googleapis.com/v1beta/openai/models" \ + -H "Authorization: Bearer $GEMINI_API_KEY" + +# With parameters +curl "https://generativelanguage.googleapis.com/v1beta/openai/chat/completions" \ + -H "Authorization: Bearer $GEMINI_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{ + "model":"gemini-2.5-flash-preview-09-2025", + "messages":[ + {"role":"system","content":"You are concise."}, + {"role":"user","content":"Name 3 planets"} + ], + "temperature":0.5, + "max_tokens":50 + }' + +# Function calling +curl "https://generativelanguage.googleapis.com/v1beta/openai/chat/completions" \ + -H "Authorization: Bearer $GEMINI_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{ + "model":"gemini-2.5-flash-preview-09-2025", + "messages":[{"role":"user","content":"What is the weather in Tokyo?"}], + "tools":[{"type":"function","function":{"name":"get_weather","parameters":{"type":"object","properties":{"location":{"type":"string"}}}}}], + "max_tokens":200 + }' +``` + +#### xAI (Grok) + +```bash +# Basic chat completion +curl https://api.x.ai/v1/chat/completions \ + -H "Authorization: Bearer $XAI_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{"model":"grok-3-mini","messages":[{"role":"user","content":"Say Hello World"}],"max_tokens":50}' + +# Streaming +curl https://api.x.ai/v1/chat/completions \ + -H "Authorization: Bearer $XAI_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{"model":"grok-3-mini","messages":[{"role":"user","content":"Say Hello World"}],"max_tokens":50,"stream":true}' + +# Models list +curl https://api.x.ai/v1/models \ + -H "Authorization: Bearer $XAI_API_KEY" + +# With parameters +curl https://api.x.ai/v1/chat/completions \ + -H "Authorization: Bearer $XAI_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{ + "model":"grok-3-mini", + "messages":[ + {"role":"system","content":"You are concise."}, + {"role":"user","content":"Name 3 planets"} + ], + "temperature":0.5, + "max_tokens":50 + }' +``` + +#### Groq + +```bash +# Basic chat completion +curl https://api.groq.com/openai/v1/chat/completions \ + -H "Authorization: Bearer $GROQ_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{"model":"llama-3.3-70b-versatile","messages":[{"role":"user","content":"Say Hello World"}],"max_tokens":50}' + +# Streaming +curl https://api.groq.com/openai/v1/chat/completions \ + -H "Authorization: Bearer $GROQ_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{"model":"llama-3.3-70b-versatile","messages":[{"role":"user","content":"Say Hello World"}],"max_tokens":50,"stream":true}' + +# Models list +curl https://api.groq.com/openai/v1/models \ + -H "Authorization: Bearer $GROQ_API_KEY" + +# With parameters +curl https://api.groq.com/openai/v1/chat/completions \ + -H "Authorization: Bearer $GROQ_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{ + "model":"llama-3.3-70b-versatile", + "messages":[ + {"role":"system","content":"You are concise."}, + {"role":"user","content":"Name 3 planets"} + ], + "temperature":0.5, + "max_tokens":50 + }' + +# Function calling +curl https://api.groq.com/openai/v1/chat/completions \ + -H "Authorization: Bearer $GROQ_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{ + "model":"llama-3.3-70b-versatile", + "messages":[{"role":"user","content":"What is the weather in London?"}], + "tools":[{"type":"function","function":{"name":"get_weather","parameters":{"type":"object","properties":{"location":{"type":"string"}}}}}], + "max_tokens":200 + }' +``` + +## Models Used + +| Provider | Model | Notes | +|----------|-------|-------| +| OpenAI | gpt-4o-mini | Standard chat model | +| OpenAI | o3-mini | Reasoning model with thinking tokens | +| Anthropic | claude-sonnet-4-20250514 | Latest Claude model | +| Gemini | gemini-2.5-flash-preview-09-2025 | Preview flash model | +| xAI | grok-3-mini | Latest Grok model | +| Groq | llama-3.3-70b-versatile | Fast inference Llama model | + +## Test Coverage + +| Feature | OpenAI | Anthropic | Gemini | xAI | Groq | +|---------|--------|-----------|--------|-----|------| +| Basic chat | ✅ | ✅ | ✅ | ✅ | ✅ | +| Streaming | ✅ | ✅ | ✅ | ✅ | ✅ | +| Models list | ✅ | - | ✅ | ✅ | ✅ | +| Tool calling | ✅ | ✅ | ✅ | - | ✅ | +| System prompt | ✅ | ✅ | ✅ | ✅ | ✅ | +| Temperature | ✅ | ✅ | ✅ | ✅ | ✅ | +| Stop sequences | ✅ | ✅ | - | - | - | +| Multi-turn | ✅ | ✅ | - | - | - | +| Multimodal | ✅ | ✅ | - | - | - | +| JSON mode | ✅ | - | - | - | - | +| Reasoning | ✅ | ✅ | - | - | - | diff --git a/tests/contract/anthropic_test.go b/tests/contract/anthropic_test.go new file mode 100644 index 00000000..fd9fcdc4 --- /dev/null +++ b/tests/contract/anthropic_test.go @@ -0,0 +1,259 @@ +//go:build contract + +package contract + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// AnthropicMessageResponse represents an Anthropic messages API response. +type AnthropicMessageResponse struct { + ID string `json:"id"` + Type string `json:"type"` + Role string `json:"role"` + Content []AnthropicContentBlock `json:"content"` + Model string `json:"model"` + StopReason string `json:"stop_reason"` + StopSequence *string `json:"stop_sequence"` + Usage AnthropicUsage `json:"usage"` +} + +// AnthropicContentBlock represents a content block in Anthropic response. +type AnthropicContentBlock struct { + Type string `json:"type"` + Text string `json:"text,omitempty"` +} + +// AnthropicUsage represents token usage in Anthropic response. +type AnthropicUsage struct { + InputTokens int `json:"input_tokens"` + OutputTokens int `json:"output_tokens"` +} + +func TestAnthropic_Messages(t *testing.T) { + if !goldenFileExists(t, "anthropic/messages.json") { + t.Skip("golden file not found - run 'make record-api' to generate") + } + + data := loadGoldenFileRaw(t, "anthropic/messages.json") + + var resp AnthropicMessageResponse + err := json.Unmarshal(data, &resp) + require.NoError(t, err, "failed to unmarshal Anthropic response") + + t.Run("Contract", func(t *testing.T) { + // Validate required fields + assert.NotEmpty(t, resp.ID, "response ID should not be empty") + assert.Equal(t, "message", resp.Type, "type should be 'message'") + assert.Equal(t, "assistant", resp.Role, "role should be 'assistant'") + assert.NotEmpty(t, resp.Model, "model should not be empty") + + // Validate content structure + require.NotEmpty(t, resp.Content, "content should not be empty") + for i, block := range resp.Content { + assert.NotEmpty(t, block.Type, "content block %d: type should not be empty", i) + if block.Type == "text" { + assert.NotEmpty(t, block.Text, "content block %d: text should not be empty for text type", i) + } + } + + // Validate stop reason + assert.NotEmpty(t, resp.StopReason, "stop_reason should not be empty") + + // Validate usage + assert.GreaterOrEqual(t, resp.Usage.InputTokens, 0, "input_tokens should be >= 0") + assert.GreaterOrEqual(t, resp.Usage.OutputTokens, 0, "output_tokens should be >= 0") + }) + + t.Run("IDFormat", func(t *testing.T) { + // Anthropic message IDs typically start with "msg_" + assert.Contains(t, resp.ID, "msg_", "Anthropic message ID should contain 'msg_'") + }) + + t.Run("Streaming", func(t *testing.T) { + if !goldenFileExists(t, "anthropic/messages_stream.txt") { + t.Skip("golden file not found - run 'make record-api' to generate") + } + + data := loadGoldenFileRaw(t, "anthropic/messages_stream.txt") + + // Anthropic streaming responses use SSE format with event types + assert.Contains(t, string(data), "event:", "streaming response should contain SSE event lines") + assert.Contains(t, string(data), "data:", "streaming response should contain SSE data lines") + + // Should contain message_start event + assert.Contains(t, string(data), "message_start", "streaming response should contain message_start event") + + // Should contain message_stop event + assert.Contains(t, string(data), "message_stop", "streaming response should contain message_stop event") + }) +} + +func TestAnthropic_MessagesWithParams(t *testing.T) { + if !goldenFileExists(t, "anthropic/messages_with_params.json") { + t.Skip("golden file not found - run 'make record-api' to generate") + } + + data := loadGoldenFileRaw(t, "anthropic/messages_with_params.json") + + var resp AnthropicMessageResponse + err := json.Unmarshal(data, &resp) + require.NoError(t, err, "failed to unmarshal Anthropic response") + + t.Run("Contract", func(t *testing.T) { + assert.NotEmpty(t, resp.ID, "response ID should not be empty") + assert.Equal(t, "message", resp.Type, "type should be 'message'") + assert.Equal(t, "assistant", resp.Role, "role should be 'assistant'") + assert.NotEmpty(t, resp.Model, "model should not be empty") + }) +} + +// AnthropicToolUseResponse represents an Anthropic response with tool use. +type AnthropicToolUseResponse struct { + ID string `json:"id"` + Type string `json:"type"` + Role string `json:"role"` + Content []AnthropicToolContentBlock `json:"content"` + Model string `json:"model"` + StopReason string `json:"stop_reason"` + StopSequence *string `json:"stop_sequence"` + Usage AnthropicUsage `json:"usage"` +} + +// AnthropicToolContentBlock represents a content block that may be text or tool_use. +type AnthropicToolContentBlock struct { + Type string `json:"type"` + Text string `json:"text,omitempty"` + ID string `json:"id,omitempty"` + Name string `json:"name,omitempty"` + Input json.RawMessage `json:"input,omitempty"` +} + +func TestAnthropic_MessagesWithTools(t *testing.T) { + if !goldenFileExists(t, "anthropic/messages_with_tools.json") { + t.Skip("golden file not found - run 'make record-api' to generate") + } + + data := loadGoldenFileRaw(t, "anthropic/messages_with_tools.json") + + var resp AnthropicToolUseResponse + err := json.Unmarshal(data, &resp) + require.NoError(t, err, "failed to unmarshal Anthropic response") + + t.Run("Contract", func(t *testing.T) { + assert.NotEmpty(t, resp.ID, "response ID should not be empty") + assert.Equal(t, "message", resp.Type, "type should be 'message'") + assert.Equal(t, "assistant", resp.Role, "role should be 'assistant'") + assert.NotEmpty(t, resp.Model, "model should not be empty") + + // Check for tool_use content blocks + hasToolUse := false + for _, block := range resp.Content { + if block.Type == "tool_use" { + hasToolUse = true + assert.NotEmpty(t, block.ID, "tool_use block should have ID") + assert.NotEmpty(t, block.Name, "tool_use block should have name") + } + } + if hasToolUse { + assert.Equal(t, "tool_use", resp.StopReason, "stop_reason should be 'tool_use' when tools are called") + } + }) +} + +// AnthropicThinkingResponse represents an Anthropic response with extended thinking. +type AnthropicThinkingResponse struct { + ID string `json:"id"` + Type string `json:"type"` + Role string `json:"role"` + Content []AnthropicThinkingContentBlock `json:"content"` + Model string `json:"model"` + StopReason string `json:"stop_reason"` + StopSequence *string `json:"stop_sequence"` + Usage AnthropicThinkingUsage `json:"usage"` +} + +// AnthropicThinkingContentBlock represents a content block that may be text or thinking. +type AnthropicThinkingContentBlock struct { + Type string `json:"type"` + Text string `json:"text,omitempty"` + Thinking string `json:"thinking,omitempty"` +} + +// AnthropicThinkingUsage represents usage with cache tokens for extended thinking. +type AnthropicThinkingUsage struct { + InputTokens int `json:"input_tokens"` + OutputTokens int `json:"output_tokens"` +} + +func TestAnthropic_ExtendedThinking(t *testing.T) { + if !goldenFileExists(t, "anthropic/messages_extended_thinking.json") { + t.Skip("golden file not found - run 'make record-api' to generate") + } + + data := loadGoldenFileRaw(t, "anthropic/messages_extended_thinking.json") + + var resp AnthropicThinkingResponse + err := json.Unmarshal(data, &resp) + require.NoError(t, err, "failed to unmarshal Anthropic response") + + t.Run("Contract", func(t *testing.T) { + assert.NotEmpty(t, resp.ID, "response ID should not be empty") + assert.Equal(t, "message", resp.Type, "type should be 'message'") + assert.Equal(t, "assistant", resp.Role, "role should be 'assistant'") + assert.NotEmpty(t, resp.Model, "model should not be empty") + + // Extended thinking responses should have thinking content block + hasThinking := false + for _, block := range resp.Content { + if block.Type == "thinking" { + hasThinking = true + assert.NotEmpty(t, block.Thinking, "thinking block should have content") + } + } + // Note: thinking may not always be present depending on the model and request + _ = hasThinking + }) +} + +func TestAnthropic_MessagesMultiTurn(t *testing.T) { + if !goldenFileExists(t, "anthropic/messages_multi_turn.json") { + t.Skip("golden file not found - run 'make record-api' to generate") + } + + data := loadGoldenFileRaw(t, "anthropic/messages_multi_turn.json") + + var resp AnthropicMessageResponse + err := json.Unmarshal(data, &resp) + require.NoError(t, err, "failed to unmarshal Anthropic response") + + t.Run("Contract", func(t *testing.T) { + assert.NotEmpty(t, resp.ID, "response ID should not be empty") + assert.Equal(t, "message", resp.Type, "type should be 'message'") + assert.Equal(t, "assistant", resp.Role, "role should be 'assistant'") + require.NotEmpty(t, resp.Content, "content should not be empty") + }) +} + +func TestAnthropic_MessagesMultimodal(t *testing.T) { + if !goldenFileExists(t, "anthropic/messages_multimodal.json") { + t.Skip("golden file not found - run 'make record-api' to generate") + } + + data := loadGoldenFileRaw(t, "anthropic/messages_multimodal.json") + + var resp AnthropicMessageResponse + err := json.Unmarshal(data, &resp) + require.NoError(t, err, "failed to unmarshal Anthropic response") + + t.Run("Contract", func(t *testing.T) { + assert.NotEmpty(t, resp.ID, "response ID should not be empty") + assert.Equal(t, "message", resp.Type, "type should be 'message'") + assert.Equal(t, "assistant", resp.Role, "role should be 'assistant'") + require.NotEmpty(t, resp.Content, "content should not be empty") + }) +} diff --git a/tests/contract/doc.go b/tests/contract/doc.go new file mode 100644 index 00000000..d68c27cb --- /dev/null +++ b/tests/contract/doc.go @@ -0,0 +1,6 @@ +// Package contract provides contract tests that validate API response structures +// against recorded golden files. These tests verify that the gateway correctly +// handles provider API responses without making actual API calls. +// +// Run with: go test -tags=contract ./tests/contract/... +package contract diff --git a/tests/contract/gemini_test.go b/tests/contract/gemini_test.go new file mode 100644 index 00000000..e7b89dab --- /dev/null +++ b/tests/contract/gemini_test.go @@ -0,0 +1,149 @@ +//go:build contract + +package contract + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "gomodel/internal/core" +) + +func TestGemini_ChatCompletion(t *testing.T) { + if !goldenFileExists(t, "gemini/chat_completion.json") { + t.Skip("golden file not found - run 'make record-api' to generate") + } + + resp := loadGoldenFile[core.ChatResponse](t, "gemini/chat_completion.json") + + t.Run("Contract", func(t *testing.T) { + // Validate required fields exist (structure validation) + assert.NotEmpty(t, resp.ID, "response ID should not be empty") + assert.NotEmpty(t, resp.Object, "response object should not be empty") + assert.Equal(t, "chat.completion", resp.Object, "object should be chat.completion") + assert.NotEmpty(t, resp.Model, "model should not be empty") + assert.NotZero(t, resp.Created, "created timestamp should not be zero") + + // Validate choices structure + require.NotEmpty(t, resp.Choices, "choices should not be empty") + choice := resp.Choices[0] + assert.GreaterOrEqual(t, choice.Index, 0, "choice index should be >= 0") + assert.NotNil(t, choice.Message, "choice message should not be nil") + assert.NotEmpty(t, choice.Message.Role, "message role should not be empty") + assert.Equal(t, "assistant", choice.Message.Role, "message role should be assistant") + assert.NotEmpty(t, choice.FinishReason, "finish reason should not be empty") + + // Validate usage structure + assert.GreaterOrEqual(t, resp.Usage.PromptTokens, 0, "prompt tokens should be >= 0") + assert.GreaterOrEqual(t, resp.Usage.CompletionTokens, 0, "completion tokens should be >= 0") + assert.GreaterOrEqual(t, resp.Usage.TotalTokens, 0, "total tokens should be >= 0") + + // Total should equal prompt + completion + expectedTotal := resp.Usage.PromptTokens + resp.Usage.CompletionTokens + assert.Equal(t, expectedTotal, resp.Usage.TotalTokens, + "total tokens should equal prompt + completion") + }) + + t.Run("ModelPrefix", func(t *testing.T) { + // Gemini models typically contain "gemini" + assert.Contains(t, resp.Model, "gemini", "Gemini model should contain 'gemini'") + }) +} + +func TestGemini_ModelsResponse_Contract(t *testing.T) { + if !goldenFileExists(t, "gemini/models.json") { + t.Skip("golden file not found - run 'make record-api' to generate") + } + + resp := loadGoldenFile[core.ModelsResponse](t, "gemini/models.json") + + // Validate required fields + assert.Equal(t, "list", resp.Object, "object should be 'list'") + assert.NotEmpty(t, resp.Data, "models list should not be empty") + + // Validate each model structure + for i, model := range resp.Data { + assert.NotEmpty(t, model.ID, "model %d: ID should not be empty", i) + assert.Equal(t, "model", model.Object, "model %d: object should be 'model'", i) + } + + // Check for some expected models (Gemini variants) + modelIDs := make(map[string]bool) + for _, model := range resp.Data { + modelIDs[model.ID] = true + } + + // At least one Gemini model should exist + hasGemini := false + for id := range modelIDs { + if containsIgnorePrefix(id, "gemini") { + hasGemini = true + break + } + } + assert.True(t, hasGemini, "expected at least one Gemini model in models list") +} + +func TestGemini_StreamingFormat_Contract(t *testing.T) { + if !goldenFileExists(t, "gemini/chat_completion_stream.txt") { + t.Skip("golden file not found - run 'make record-api' to generate") + } + + data := loadGoldenFileRaw(t, "gemini/chat_completion_stream.txt") + + // Streaming responses should be in SSE format + assert.Contains(t, string(data), "data:", "streaming response should contain SSE data lines") + + // Should end with [DONE] + assert.Contains(t, string(data), "[DONE]", "streaming response should end with [DONE]") +} + +func TestGemini_ChatWithParams(t *testing.T) { + if !goldenFileExists(t, "gemini/chat_with_params.json") { + t.Skip("golden file not found - run 'make record-api' to generate") + } + + resp := loadGoldenFile[core.ChatResponse](t, "gemini/chat_with_params.json") + + t.Run("Contract", func(t *testing.T) { + assert.NotEmpty(t, resp.ID, "response ID should not be empty") + assert.Equal(t, "chat.completion", resp.Object, "object should be chat.completion") + require.NotEmpty(t, resp.Choices, "choices should not be empty") + assert.Equal(t, "assistant", resp.Choices[0].Message.Role, "message role should be assistant") + }) +} + +func TestGemini_ChatWithTools(t *testing.T) { + if !goldenFileExists(t, "gemini/chat_with_tools.json") { + t.Skip("golden file not found - run 'make record-api' to generate") + } + + resp := loadGoldenFile[ToolCallResponse](t, "gemini/chat_with_tools.json") + + t.Run("Contract", func(t *testing.T) { + assert.NotEmpty(t, resp.ID, "response ID should not be empty") + assert.Equal(t, "chat.completion", resp.Object, "object should be chat.completion") + require.NotEmpty(t, resp.Choices, "choices should not be empty") + + choice := resp.Choices[0] + // Tool calls may be present if the model chose to use tools + if len(choice.Message.ToolCalls) > 0 { + for i, tc := range choice.Message.ToolCalls { + assert.NotEmpty(t, tc.ID, "tool call %d: ID should not be empty", i) + assert.Equal(t, "function", tc.Type, "tool call %d: type should be 'function'", i) + assert.NotEmpty(t, tc.Function.Name, "tool call %d: function name should not be empty", i) + } + } + }) +} + +// containsIgnorePrefix checks if a string contains a substring, ignoring "models/" prefix +func containsIgnorePrefix(s, substr string) bool { + // Strip common prefixes + if len(s) > 7 && s[:7] == "models/" { + s = s[7:] + } + return len(s) >= len(substr) && s[:len(substr)] == substr +} diff --git a/tests/contract/groq_test.go b/tests/contract/groq_test.go new file mode 100644 index 00000000..1d10160c --- /dev/null +++ b/tests/contract/groq_test.go @@ -0,0 +1,141 @@ +//go:build contract + +package contract + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "gomodel/internal/core" +) + +func TestGroq_ChatCompletion(t *testing.T) { + if !goldenFileExists(t, "groq/chat_completion.json") { + t.Skip("golden file not found - run 'make record-api' to generate") + } + + resp := loadGoldenFile[core.ChatResponse](t, "groq/chat_completion.json") + + t.Run("Contract", func(t *testing.T) { + // Validate required fields exist (structure validation) + assert.NotEmpty(t, resp.ID, "response ID should not be empty") + assert.NotEmpty(t, resp.Object, "response object should not be empty") + assert.Equal(t, "chat.completion", resp.Object, "object should be chat.completion") + assert.NotEmpty(t, resp.Model, "model should not be empty") + assert.NotZero(t, resp.Created, "created timestamp should not be zero") + + // Validate choices structure + require.NotEmpty(t, resp.Choices, "choices should not be empty") + choice := resp.Choices[0] + assert.GreaterOrEqual(t, choice.Index, 0, "choice index should be >= 0") + assert.NotNil(t, choice.Message, "choice message should not be nil") + assert.NotEmpty(t, choice.Message.Role, "message role should not be empty") + assert.Equal(t, "assistant", choice.Message.Role, "message role should be assistant") + assert.NotEmpty(t, choice.FinishReason, "finish reason should not be empty") + + // Validate usage structure + assert.GreaterOrEqual(t, resp.Usage.PromptTokens, 0, "prompt tokens should be >= 0") + assert.GreaterOrEqual(t, resp.Usage.CompletionTokens, 0, "completion tokens should be >= 0") + assert.GreaterOrEqual(t, resp.Usage.TotalTokens, 0, "total tokens should be >= 0") + + // Total should equal prompt + completion + expectedTotal := resp.Usage.PromptTokens + resp.Usage.CompletionTokens + assert.Equal(t, expectedTotal, resp.Usage.TotalTokens, + "total tokens should equal prompt + completion") + }) + + t.Run("IDFormat", func(t *testing.T) { + // Groq response IDs typically start with "chatcmpl-" + assert.Contains(t, resp.ID, "chatcmpl-", "Groq chat completion ID should contain 'chatcmpl-'") + }) +} + +func TestGroq_ModelsResponse_Contract(t *testing.T) { + if !goldenFileExists(t, "groq/models.json") { + t.Skip("golden file not found - run 'make record-api' to generate") + } + + resp := loadGoldenFile[core.ModelsResponse](t, "groq/models.json") + + // Validate required fields + assert.Equal(t, "list", resp.Object, "object should be 'list'") + assert.NotEmpty(t, resp.Data, "models list should not be empty") + + // Validate each model structure + for i, model := range resp.Data { + assert.NotEmpty(t, model.ID, "model %d: ID should not be empty", i) + assert.Equal(t, "model", model.Object, "model %d: object should be 'model'", i) + assert.NotEmpty(t, model.OwnedBy, "model %d: owned_by should not be empty", i) + } + + // Check for some expected models (Llama variants) + modelIDs := make(map[string]bool) + for _, model := range resp.Data { + modelIDs[model.ID] = true + } + + // At least one Llama model should exist + hasLlama := false + for id := range modelIDs { + if len(id) >= 5 && id[:5] == "llama" { + hasLlama = true + break + } + } + assert.True(t, hasLlama, "expected at least one Llama model in models list") +} + +func TestGroq_StreamingFormat_Contract(t *testing.T) { + if !goldenFileExists(t, "groq/chat_completion_stream.txt") { + t.Skip("golden file not found - run 'make record-api' to generate") + } + + data := loadGoldenFileRaw(t, "groq/chat_completion_stream.txt") + + // Streaming responses should be in SSE format + assert.Contains(t, string(data), "data:", "streaming response should contain SSE data lines") + + // Should end with [DONE] + assert.Contains(t, string(data), "[DONE]", "streaming response should end with [DONE]") +} + +func TestGroq_ChatWithParams(t *testing.T) { + if !goldenFileExists(t, "groq/chat_with_params.json") { + t.Skip("golden file not found - run 'make record-api' to generate") + } + + resp := loadGoldenFile[core.ChatResponse](t, "groq/chat_with_params.json") + + t.Run("Contract", func(t *testing.T) { + assert.NotEmpty(t, resp.ID, "response ID should not be empty") + assert.Equal(t, "chat.completion", resp.Object, "object should be chat.completion") + require.NotEmpty(t, resp.Choices, "choices should not be empty") + assert.Equal(t, "assistant", resp.Choices[0].Message.Role, "message role should be assistant") + }) +} + +func TestGroq_ChatWithTools(t *testing.T) { + if !goldenFileExists(t, "groq/chat_with_tools.json") { + t.Skip("golden file not found - run 'make record-api' to generate") + } + + resp := loadGoldenFile[ToolCallResponse](t, "groq/chat_with_tools.json") + + t.Run("Contract", func(t *testing.T) { + assert.NotEmpty(t, resp.ID, "response ID should not be empty") + assert.Equal(t, "chat.completion", resp.Object, "object should be chat.completion") + require.NotEmpty(t, resp.Choices, "choices should not be empty") + + choice := resp.Choices[0] + // Tool calls may be present if the model chose to use tools + if len(choice.Message.ToolCalls) > 0 { + for i, tc := range choice.Message.ToolCalls { + assert.NotEmpty(t, tc.ID, "tool call %d: ID should not be empty", i) + assert.Equal(t, "function", tc.Type, "tool call %d: type should be 'function'", i) + assert.NotEmpty(t, tc.Function.Name, "tool call %d: function name should not be empty", i) + } + } + }) +} diff --git a/tests/contract/main_test.go b/tests/contract/main_test.go new file mode 100644 index 00000000..580952eb --- /dev/null +++ b/tests/contract/main_test.go @@ -0,0 +1,94 @@ +//go:build contract + +// Package contract provides contract tests that validate API response structures +// against recorded golden files. These tests verify that the gateway correctly +// handles provider API responses without making actual API calls. +package contract + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" +) + +// testdataDir is the path to the testdata directory. +const testdataDir = "testdata" + +// loadGoldenFile reads a golden file from testdata and unmarshals it. +func loadGoldenFile[T any](t *testing.T, path string) T { + t.Helper() + + fullPath := filepath.Join(testdataDir, path) + data, err := os.ReadFile(fullPath) + require.NoError(t, err, "failed to read golden file %s", fullPath) + + var result T + err = json.Unmarshal(data, &result) + require.NoError(t, err, "failed to unmarshal golden file %s", fullPath) + + return result +} + +// loadGoldenFileRaw reads a golden file from testdata as raw bytes. +func loadGoldenFileRaw(t *testing.T, path string) []byte { + t.Helper() + + fullPath := filepath.Join(testdataDir, path) + data, err := os.ReadFile(fullPath) + require.NoError(t, err, "failed to read golden file %s", fullPath) + + return data +} + +// goldenFileExists checks if a golden file exists. +func goldenFileExists(t *testing.T, path string) bool { + t.Helper() + + fullPath := filepath.Join(testdataDir, path) + _, err := os.Stat(fullPath) + return err == nil +} + +// ToolCallResponse represents a chat response that may include tool calls. +type ToolCallResponse struct { + ID string `json:"id"` + Object string `json:"object"` + Model string `json:"model"` + Choices []ToolCallChoice `json:"choices"` + Usage struct { + PromptTokens int `json:"prompt_tokens"` + CompletionTokens int `json:"completion_tokens"` + TotalTokens int `json:"total_tokens"` + } `json:"usage"` + Created int64 `json:"created"` +} + +// ToolCallChoice represents a choice that may include tool calls. +type ToolCallChoice struct { + Message ToolCallMessage `json:"message"` + FinishReason string `json:"finish_reason"` + Index int `json:"index"` +} + +// ToolCallMessage represents a message that may include tool calls. +type ToolCallMessage struct { + Role string `json:"role"` + Content string `json:"content"` + ToolCalls []ToolCall `json:"tool_calls,omitempty"` +} + +// ToolCall represents a tool call in a message. +type ToolCall struct { + ID string `json:"id"` + Type string `json:"type"` + Function FunctionCall `json:"function"` +} + +// FunctionCall represents the function details in a tool call. +type FunctionCall struct { + Name string `json:"name"` + Arguments string `json:"arguments"` +} diff --git a/tests/contract/openai_test.go b/tests/contract/openai_test.go new file mode 100644 index 00000000..c45ed5f3 --- /dev/null +++ b/tests/contract/openai_test.go @@ -0,0 +1,221 @@ +//go:build contract + +package contract + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "gomodel/internal/core" +) + +func TestOpenAI_ChatCompletion(t *testing.T) { + if !goldenFileExists(t, "openai/chat_completion.json") { + t.Skip("golden file not found - run 'make record-api' to generate") + } + + resp := loadGoldenFile[core.ChatResponse](t, "openai/chat_completion.json") + + t.Run("Contract", func(t *testing.T) { + // Validate required fields exist (structure validation) + assert.NotEmpty(t, resp.ID, "response ID should not be empty") + assert.NotEmpty(t, resp.Object, "response object should not be empty") + assert.Equal(t, "chat.completion", resp.Object, "object should be chat.completion") + assert.NotEmpty(t, resp.Model, "model should not be empty") + assert.NotZero(t, resp.Created, "created timestamp should not be zero") + + // Validate choices structure + require.NotEmpty(t, resp.Choices, "choices should not be empty") + choice := resp.Choices[0] + assert.GreaterOrEqual(t, choice.Index, 0, "choice index should be >= 0") + assert.NotNil(t, choice.Message, "choice message should not be nil") + assert.NotEmpty(t, choice.Message.Role, "message role should not be empty") + assert.Equal(t, "assistant", choice.Message.Role, "message role should be assistant") + assert.NotEmpty(t, choice.FinishReason, "finish reason should not be empty") + + // Validate usage structure + assert.GreaterOrEqual(t, resp.Usage.PromptTokens, 0, "prompt tokens should be >= 0") + assert.GreaterOrEqual(t, resp.Usage.CompletionTokens, 0, "completion tokens should be >= 0") + assert.GreaterOrEqual(t, resp.Usage.TotalTokens, 0, "total tokens should be >= 0") + + // Total should equal prompt + completion + expectedTotal := resp.Usage.PromptTokens + resp.Usage.CompletionTokens + assert.Equal(t, expectedTotal, resp.Usage.TotalTokens, + "total tokens should equal prompt + completion") + }) + + t.Run("IDFormat", func(t *testing.T) { + // OpenAI response IDs typically start with "chatcmpl-" + assert.Contains(t, resp.ID, "chatcmpl-", "OpenAI chat completion ID should contain 'chatcmpl-'") + }) +} + +func TestOpenAI_ModelsResponse_Contract(t *testing.T) { + if !goldenFileExists(t, "openai/models.json") { + t.Skip("golden file not found - run 'make record-api' to generate") + } + + resp := loadGoldenFile[core.ModelsResponse](t, "openai/models.json") + + // Validate required fields + assert.Equal(t, "list", resp.Object, "object should be 'list'") + assert.NotEmpty(t, resp.Data, "models list should not be empty") + + // Validate each model structure + for i, model := range resp.Data { + assert.NotEmpty(t, model.ID, "model %d: ID should not be empty", i) + assert.Equal(t, "model", model.Object, "model %d: object should be 'model'", i) + assert.NotEmpty(t, model.OwnedBy, "model %d: owned_by should not be empty", i) + } + + // Check for some expected models + modelIDs := make(map[string]bool) + for _, model := range resp.Data { + modelIDs[model.ID] = true + } + + // GPT-4 variants should exist (at least one) + hasGPT4 := modelIDs["gpt-4"] || modelIDs["gpt-4-turbo"] || modelIDs["gpt-4o"] || modelIDs["gpt-4o-mini"] + assert.True(t, hasGPT4, "expected at least one GPT-4 variant in models list") +} + +func TestOpenAI_StreamingFormat_Contract(t *testing.T) { + if !goldenFileExists(t, "openai/chat_completion_stream.txt") { + t.Skip("golden file not found - run 'make record-api' to generate") + } + + data := loadGoldenFileRaw(t, "openai/chat_completion_stream.txt") + + // Streaming responses should be in SSE format + assert.Contains(t, string(data), "data:", "streaming response should contain SSE data lines") + + // Should end with [DONE] + assert.Contains(t, string(data), "[DONE]", "streaming response should end with [DONE]") +} + +func TestOpenAI_ReasoningModel(t *testing.T) { + if !goldenFileExists(t, "openai/chat_completion_reasoning.json") { + t.Skip("golden file not found - run 'make record-api' to generate") + } + + resp := loadGoldenFile[core.ChatResponse](t, "openai/chat_completion_reasoning.json") + + t.Run("Contract", func(t *testing.T) { + // Validate required fields + assert.NotEmpty(t, resp.ID, "response ID should not be empty") + assert.Equal(t, "chat.completion", resp.Object, "object should be chat.completion") + assert.NotEmpty(t, resp.Model, "model should not be empty") + assert.NotZero(t, resp.Created, "created timestamp should not be zero") + + // Validate choices + require.NotEmpty(t, resp.Choices, "choices should not be empty") + choice := resp.Choices[0] + assert.Equal(t, "assistant", choice.Message.Role, "message role should be assistant") + assert.NotEmpty(t, choice.FinishReason, "finish reason should not be empty") + }) + + t.Run("ModelType", func(t *testing.T) { + // Reasoning models contain "o1" or "o3" in their name + isReasoningModel := false + if len(resp.Model) >= 2 { + prefix := resp.Model[:2] + if prefix == "o1" || prefix == "o3" { + isReasoningModel = true + } + } + assert.True(t, isReasoningModel, "model should be a reasoning model (o1 or o3 series)") + }) +} + +func TestOpenAI_ChatWithTools(t *testing.T) { + if !goldenFileExists(t, "openai/chat_with_tools.json") { + t.Skip("golden file not found - run 'make record-api' to generate") + } + + resp := loadGoldenFile[ToolCallResponse](t, "openai/chat_with_tools.json") + + t.Run("Contract", func(t *testing.T) { + assert.NotEmpty(t, resp.ID, "response ID should not be empty") + assert.Equal(t, "chat.completion", resp.Object, "object should be chat.completion") + require.NotEmpty(t, resp.Choices, "choices should not be empty") + + choice := resp.Choices[0] + // Tool calls should be present when model uses function calling + if len(choice.Message.ToolCalls) > 0 { + for i, tc := range choice.Message.ToolCalls { + assert.NotEmpty(t, tc.ID, "tool call %d: ID should not be empty", i) + assert.Equal(t, "function", tc.Type, "tool call %d: type should be 'function'", i) + assert.NotEmpty(t, tc.Function.Name, "tool call %d: function name should not be empty", i) + } + assert.Equal(t, "tool_calls", choice.FinishReason, "finish reason should be 'tool_calls' when tools are called") + } + }) +} + +func TestOpenAI_ChatWithParams(t *testing.T) { + if !goldenFileExists(t, "openai/chat_with_params.json") { + t.Skip("golden file not found - run 'make record-api' to generate") + } + + resp := loadGoldenFile[core.ChatResponse](t, "openai/chat_with_params.json") + + t.Run("Contract", func(t *testing.T) { + assert.NotEmpty(t, resp.ID, "response ID should not be empty") + assert.Equal(t, "chat.completion", resp.Object, "object should be chat.completion") + require.NotEmpty(t, resp.Choices, "choices should not be empty") + assert.Equal(t, "assistant", resp.Choices[0].Message.Role, "message role should be assistant") + + // When stop sequence is hit, finish_reason should be "stop" + assert.Equal(t, "stop", resp.Choices[0].FinishReason, "finish reason should be 'stop'") + }) +} + +func TestOpenAI_ChatMultiTurn(t *testing.T) { + if !goldenFileExists(t, "openai/chat_multi_turn.json") { + t.Skip("golden file not found - run 'make record-api' to generate") + } + + resp := loadGoldenFile[core.ChatResponse](t, "openai/chat_multi_turn.json") + + t.Run("Contract", func(t *testing.T) { + assert.NotEmpty(t, resp.ID, "response ID should not be empty") + assert.Equal(t, "chat.completion", resp.Object, "object should be chat.completion") + require.NotEmpty(t, resp.Choices, "choices should not be empty") + assert.Equal(t, "assistant", resp.Choices[0].Message.Role, "message role should be assistant") + assert.NotEmpty(t, resp.Choices[0].Message.Content, "message content should not be empty") + }) +} + +func TestOpenAI_ChatMultimodal(t *testing.T) { + if !goldenFileExists(t, "openai/chat_multimodal.json") { + t.Skip("golden file not found - run 'make record-api' to generate") + } + + resp := loadGoldenFile[core.ChatResponse](t, "openai/chat_multimodal.json") + + t.Run("Contract", func(t *testing.T) { + assert.NotEmpty(t, resp.ID, "response ID should not be empty") + assert.Equal(t, "chat.completion", resp.Object, "object should be chat.completion") + require.NotEmpty(t, resp.Choices, "choices should not be empty") + assert.Equal(t, "assistant", resp.Choices[0].Message.Role, "message role should be assistant") + assert.NotEmpty(t, resp.Choices[0].Message.Content, "message content should not be empty") + }) +} + +func TestOpenAI_JSONMode(t *testing.T) { + if !goldenFileExists(t, "openai/chat_json_mode.json") { + t.Skip("golden file not found - run 'make record-api' to generate") + } + + resp := loadGoldenFile[core.ChatResponse](t, "openai/chat_json_mode.json") + + t.Run("Contract", func(t *testing.T) { + assert.NotEmpty(t, resp.ID, "response ID should not be empty") + assert.Equal(t, "chat.completion", resp.Object, "object should be chat.completion") + require.NotEmpty(t, resp.Choices, "choices should not be empty") + assert.Equal(t, "assistant", resp.Choices[0].Message.Role, "message role should be assistant") + assert.NotEmpty(t, resp.Choices[0].Message.Content, "message content should not be empty") + }) +} diff --git a/tests/contract/testdata/anthropic/messages.json b/tests/contract/testdata/anthropic/messages.json new file mode 100644 index 00000000..a65f8b8a --- /dev/null +++ b/tests/contract/testdata/anthropic/messages.json @@ -0,0 +1,18 @@ +{ + "model": "claude-sonnet-4-20250514", + "id": "msg_013LBDy1nvGiRgxiRAWseYRS", + "type": "message", + "role": "assistant", + "content": [ + { + "type": "text", + "text": "Hello World" + } + ], + "stop_reason": "end_turn", + "stop_sequence": null, + "usage": { + "input_tokens": 14, + "output_tokens": 5 + } +} diff --git a/tests/contract/testdata/anthropic/messages_extended_thinking.json b/tests/contract/testdata/anthropic/messages_extended_thinking.json new file mode 100644 index 00000000..59cd315f --- /dev/null +++ b/tests/contract/testdata/anthropic/messages_extended_thinking.json @@ -0,0 +1,30 @@ +{ + "model": "claude-sonnet-4-20250514", + "id": "msg_01MHEipmYGTxtwNoLd4876Ug", + "type": "message", + "role": "assistant", + "content": [ + { + "type": "thinking", + "thinking": "I need to solve for x in the equation x + 5 = 12.\n\nTo isolate x, I need to subtract 5 from both sides of the equation:\n\nx + 5 = 12\nx + 5 - 5 = 12 - 5\nx = 7\n\nLet me check: If x = 7, then x + 5 = 7 + 5 = 12 ✓\n\nSo x = 7.", + "signature": "EoQDCkYICxgCKkD/7LGRV3nhFx7eDr0a8CqN9Q7VPNpcIwqMFQzxg6yM6cO8+q0blgDq+FxNpRMTkCjr3IJM6NaGVts1UOfeU5aNEgxb0pVzXeXVpI7s+Z4aDE478qswmtW5VM64tCIw0s9xrQpgRYkAQXva9t8ffdPRTNonQJha21BqfjukLhWjHR9NjkDoP7Qwsg1Y/puqKusBe7vIpKbZDnINky4NLhrdk10PAhtp428bEsRkJYKhoSfNfmxO6KQUH4xA7zXA4FWNwrrWtbaq8Wjy7A6NQ5RMCzxqOa1gzUpI06JOJf47QcjJuJmu6IzjL8Wj6e0vg1v8AQyiLQRh+vMlTkCmT1p2rmmUfrqGA68oEKWt6HkytfiRrkw3UrbIqjOlPlWYmFSh3ypnYHV91QILr8uoaRivkLko8eTMnS2dq8qKBJyH+nZvKjNnQg2fz4Xj25wfDyiB6dQ2zXPL1vdMurqfzsM2ZKoihxVGCPkfstDG24iadpjrH09hqTriwIVspRgB" + }, + { + "type": "text", + "text": "To solve for x in the equation x + 5 = 12, I need to isolate x.\n\nI'll subtract 5 from both sides:\nx + 5 = 12\nx + 5 - 5 = 12 - 5\nx = 7\n\nLet me verify: 7 + 5 = 12 ✓\n\nTherefore, x = 7." + } + ], + "stop_reason": "end_turn", + "stop_sequence": null, + "usage": { + "input_tokens": 53, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 0, + "cache_creation": { + "ephemeral_5m_input_tokens": 0, + "ephemeral_1h_input_tokens": 0 + }, + "output_tokens": 211, + "service_tier": "standard" + } +} diff --git a/tests/contract/testdata/anthropic/messages_multi_turn.json b/tests/contract/testdata/anthropic/messages_multi_turn.json new file mode 100644 index 00000000..106de5ec --- /dev/null +++ b/tests/contract/testdata/anthropic/messages_multi_turn.json @@ -0,0 +1,25 @@ +{ + "model": "claude-sonnet-4-20250514", + "id": "msg_01B3jbiKZLmG7XyMkgNMLirX", + "type": "message", + "role": "assistant", + "content": [ + { + "type": "text", + "text": "16" + } + ], + "stop_reason": "end_turn", + "stop_sequence": null, + "usage": { + "input_tokens": 28, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 0, + "cache_creation": { + "ephemeral_5m_input_tokens": 0, + "ephemeral_1h_input_tokens": 0 + }, + "output_tokens": 5, + "service_tier": "standard" + } +} diff --git a/tests/contract/testdata/anthropic/messages_multimodal.json b/tests/contract/testdata/anthropic/messages_multimodal.json new file mode 100644 index 00000000..542b431b --- /dev/null +++ b/tests/contract/testdata/anthropic/messages_multimodal.json @@ -0,0 +1,25 @@ +{ + "model": "claude-sonnet-4-20250514", + "id": "msg_01EhF3inwGP9NXuBMrwkUWy1", + "type": "message", + "role": "assistant", + "content": [ + { + "type": "text", + "text": "In this Google logo, I can see four main colors:\n\n1. **Blue** - used for the \"G\", \"g\", and \"l\"\n2. **Red** - used for the first \"o\" and \"e\"\n3. **Yellow** - used for the second \"o\"\n4. **Green** - used for the \"l\"\n\nThese are Google's signature brand colors that they use consistently across their logo and branding materials." + } + ], + "stop_reason": "end_turn", + "stop_sequence": null, + "usage": { + "input_tokens": 159, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 0, + "cache_creation": { + "ephemeral_5m_input_tokens": 0, + "ephemeral_1h_input_tokens": 0 + }, + "output_tokens": 100, + "service_tier": "standard" + } +} diff --git a/tests/contract/testdata/anthropic/messages_stream.txt b/tests/contract/testdata/anthropic/messages_stream.txt new file mode 100644 index 00000000..9dcb2737 --- /dev/null +++ b/tests/contract/testdata/anthropic/messages_stream.txt @@ -0,0 +1,20 @@ +event: message_start +data: {"type":"message_start","message":{"model":"claude-sonnet-4-20250514","id":"msg_01SJmPi7XXP8qscFQiHPR9oy","type":"message","role":"assistant","content":[],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":10,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":0},"output_tokens":3,"service_tier":"standard"}} } + +event: content_block_start +data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""} } + +event: ping +data: {"type": "ping"} + +event: content_block_delta +data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hello World"} } + +event: content_block_stop +data: {"type":"content_block_stop","index":0 } + +event: message_delta +data: {"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null},"usage":{"input_tokens":10,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"output_tokens":5} } + +event: message_stop +data: {"type":"message_stop" } diff --git a/tests/contract/testdata/anthropic/messages_with_params.json b/tests/contract/testdata/anthropic/messages_with_params.json new file mode 100644 index 00000000..126bd834 --- /dev/null +++ b/tests/contract/testdata/anthropic/messages_with_params.json @@ -0,0 +1,25 @@ +{ + "model": "claude-sonnet-4-20250514", + "id": "msg_01J9EE5VSWbER7SNDLc82bwt", + "type": "message", + "role": "assistant", + "content": [ + { + "type": "text", + "text": "1, 2, " + } + ], + "stop_reason": "stop_sequence", + "stop_sequence": "3", + "usage": { + "input_tokens": 25, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 0, + "cache_creation": { + "ephemeral_5m_input_tokens": 0, + "ephemeral_1h_input_tokens": 0 + }, + "output_tokens": 8, + "service_tier": "standard" + } +} diff --git a/tests/contract/testdata/anthropic/messages_with_tools.json b/tests/contract/testdata/anthropic/messages_with_tools.json new file mode 100644 index 00000000..eda83a78 --- /dev/null +++ b/tests/contract/testdata/anthropic/messages_with_tools.json @@ -0,0 +1,33 @@ +{ + "model": "claude-sonnet-4-20250514", + "id": "msg_01LEocKDcwMbVwu5WfQD4F9e", + "type": "message", + "role": "assistant", + "content": [ + { + "type": "text", + "text": "I'll check the current weather in Paris for you." + }, + { + "type": "tool_use", + "id": "toolu_01Dq95J3WcnLmvSvBpAuaXwi", + "name": "get_weather", + "input": { + "location": "Paris" + } + } + ], + "stop_reason": "tool_use", + "stop_sequence": null, + "usage": { + "input_tokens": 380, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 0, + "cache_creation": { + "ephemeral_5m_input_tokens": 0, + "ephemeral_1h_input_tokens": 0 + }, + "output_tokens": 65, + "service_tier": "standard" + } +} diff --git a/tests/contract/testdata/gemini/chat_completion.json b/tests/contract/testdata/gemini/chat_completion.json new file mode 100644 index 00000000..d8d12036 --- /dev/null +++ b/tests/contract/testdata/gemini/chat_completion.json @@ -0,0 +1,21 @@ +{ + "choices": [ + { + "finish_reason": "stop", + "index": 0, + "message": { + "content": "Hello World", + "role": "assistant" + } + } + ], + "created": 1769093697, + "id": "QTpyabuaIfaevdIPzqPKkQU", + "model": "gemini-2.5-flash-preview-09-2025", + "object": "chat.completion", + "usage": { + "completion_tokens": 2, + "prompt_tokens": 4, + "total_tokens": 6 + } +} diff --git a/tests/contract/testdata/gemini/chat_completion_stream.txt b/tests/contract/testdata/gemini/chat_completion_stream.txt new file mode 100644 index 00000000..c03d9761 --- /dev/null +++ b/tests/contract/testdata/gemini/chat_completion_stream.txt @@ -0,0 +1,3 @@ +data: {"choices":[{"delta":{"content":"Hello World","role":"assistant"},"finish_reason":"stop","index":0}],"created":1769093707,"id":"SzpyaZuaHu_WxN8P9M-PgA4","model":"gemini-2.5-flash-preview-09-2025","object":"chat.completion.chunk"} + +data: [DONE] diff --git a/tests/contract/testdata/gemini/chat_with_params.json b/tests/contract/testdata/gemini/chat_with_params.json new file mode 100644 index 00000000..134422fd --- /dev/null +++ b/tests/contract/testdata/gemini/chat_with_params.json @@ -0,0 +1,21 @@ +{ + "choices": [ + { + "finish_reason": "stop", + "index": 0, + "message": { + "content": "Earth, Mars, Venus", + "role": "assistant" + } + } + ], + "created": 1769093857, + "id": "4TpyaZG4A5fJvdIPi6WJ-Ac", + "model": "gemini-2.5-flash-preview-09-2025", + "object": "chat.completion", + "usage": { + "completion_tokens": 5, + "prompt_tokens": 10, + "total_tokens": 15 + } +} diff --git a/tests/contract/testdata/gemini/chat_with_tools.json b/tests/contract/testdata/gemini/chat_with_tools.json new file mode 100644 index 00000000..d73b2dcd --- /dev/null +++ b/tests/contract/testdata/gemini/chat_with_tools.json @@ -0,0 +1,30 @@ +{ + "choices": [ + { + "finish_reason": "tool_calls", + "index": 0, + "message": { + "role": "assistant", + "tool_calls": [ + { + "function": { + "arguments": "{\"location\":\"Tokyo\"}", + "name": "get_weather" + }, + "id": "function-call-21392045118397310", + "type": "function" + } + ] + } + } + ], + "created": 1769093859, + "id": "4zpyaaPLGYXlxN8Pq7jUkQs", + "model": "gemini-2.5-flash-preview-09-2025", + "object": "chat.completion", + "usage": { + "completion_tokens": 19, + "prompt_tokens": 44, + "total_tokens": 63 + } +} diff --git a/tests/contract/testdata/gemini/models.json b/tests/contract/testdata/gemini/models.json new file mode 100644 index 00000000..ce4b7c82 --- /dev/null +++ b/tests/contract/testdata/gemini/models.json @@ -0,0 +1,323 @@ +{ + "object": "list", + "data": [ + { + "id": "models/embedding-gecko-001", + "object": "model", + "owned_by": "google", + "display_name": "Embedding Gecko" + }, + { + "id": "models/gemini-2.5-flash", + "object": "model", + "owned_by": "google", + "display_name": "Gemini 2.5 Flash" + }, + { + "id": "models/gemini-2.5-pro", + "object": "model", + "owned_by": "google", + "display_name": "Gemini 2.5 Pro" + }, + { + "id": "models/gemini-2.0-flash-exp", + "object": "model", + "owned_by": "google", + "display_name": "Gemini 2.0 Flash Experimental" + }, + { + "id": "models/gemini-2.0-flash", + "object": "model", + "owned_by": "google", + "display_name": "Gemini 2.0 Flash" + }, + { + "id": "models/gemini-2.0-flash-001", + "object": "model", + "owned_by": "google", + "display_name": "Gemini 2.0 Flash 001" + }, + { + "id": "models/gemini-2.0-flash-lite-001", + "object": "model", + "owned_by": "google", + "display_name": "Gemini 2.0 Flash-Lite 001" + }, + { + "id": "models/gemini-2.0-flash-lite", + "object": "model", + "owned_by": "google", + "display_name": "Gemini 2.0 Flash-Lite" + }, + { + "id": "models/gemini-2.0-flash-lite-preview-02-05", + "object": "model", + "owned_by": "google", + "display_name": "Gemini 2.0 Flash-Lite Preview 02-05" + }, + { + "id": "models/gemini-2.0-flash-lite-preview", + "object": "model", + "owned_by": "google", + "display_name": "Gemini 2.0 Flash-Lite Preview" + }, + { + "id": "models/gemini-exp-1206", + "object": "model", + "owned_by": "google", + "display_name": "Gemini Experimental 1206" + }, + { + "id": "models/gemini-2.5-flash-preview-tts", + "object": "model", + "owned_by": "google", + "display_name": "Gemini 2.5 Flash Preview TTS" + }, + { + "id": "models/gemini-2.5-pro-preview-tts", + "object": "model", + "owned_by": "google", + "display_name": "Gemini 2.5 Pro Preview TTS" + }, + { + "id": "models/gemma-3-1b-it", + "object": "model", + "owned_by": "google", + "display_name": "Gemma 3 1B" + }, + { + "id": "models/gemma-3-4b-it", + "object": "model", + "owned_by": "google", + "display_name": "Gemma 3 4B" + }, + { + "id": "models/gemma-3-12b-it", + "object": "model", + "owned_by": "google", + "display_name": "Gemma 3 12B" + }, + { + "id": "models/gemma-3-27b-it", + "object": "model", + "owned_by": "google", + "display_name": "Gemma 3 27B" + }, + { + "id": "models/gemma-3n-e4b-it", + "object": "model", + "owned_by": "google", + "display_name": "Gemma 3n E4B" + }, + { + "id": "models/gemma-3n-e2b-it", + "object": "model", + "owned_by": "google", + "display_name": "Gemma 3n E2B" + }, + { + "id": "models/gemini-flash-latest", + "object": "model", + "owned_by": "google", + "display_name": "Gemini Flash Latest" + }, + { + "id": "models/gemini-flash-lite-latest", + "object": "model", + "owned_by": "google", + "display_name": "Gemini Flash-Lite Latest" + }, + { + "id": "models/gemini-pro-latest", + "object": "model", + "owned_by": "google", + "display_name": "Gemini Pro Latest" + }, + { + "id": "models/gemini-2.5-flash-lite", + "object": "model", + "owned_by": "google", + "display_name": "Gemini 2.5 Flash-Lite" + }, + { + "id": "models/gemini-2.5-flash-image", + "object": "model", + "owned_by": "google", + "display_name": "Nano Banana" + }, + { + "id": "models/gemini-2.5-flash-preview-09-2025", + "object": "model", + "owned_by": "google", + "display_name": "Gemini 2.5 Flash Preview Sep 2025" + }, + { + "id": "models/gemini-2.5-flash-lite-preview-09-2025", + "object": "model", + "owned_by": "google", + "display_name": "Gemini 2.5 Flash-Lite Preview Sep 2025" + }, + { + "id": "models/gemini-3-pro-preview", + "object": "model", + "owned_by": "google", + "display_name": "Gemini 3 Pro Preview" + }, + { + "id": "models/gemini-3-flash-preview", + "object": "model", + "owned_by": "google", + "display_name": "Gemini 3 Flash Preview" + }, + { + "id": "models/gemini-3-pro-image-preview", + "object": "model", + "owned_by": "google", + "display_name": "Nano Banana Pro" + }, + { + "id": "models/nano-banana-pro-preview", + "object": "model", + "owned_by": "google", + "display_name": "Nano Banana Pro" + }, + { + "id": "models/gemini-robotics-er-1.5-preview", + "object": "model", + "owned_by": "google", + "display_name": "Gemini Robotics-ER 1.5 Preview" + }, + { + "id": "models/gemini-2.5-computer-use-preview-10-2025", + "object": "model", + "owned_by": "google", + "display_name": "Gemini 2.5 Computer Use Preview 10-2025" + }, + { + "id": "models/deep-research-pro-preview-12-2025", + "object": "model", + "owned_by": "google", + "display_name": "Deep Research Pro Preview (Dec-12-2025)" + }, + { + "id": "models/embedding-001", + "object": "model", + "owned_by": "google", + "display_name": "Embedding 001" + }, + { + "id": "models/text-embedding-004", + "object": "model", + "owned_by": "google", + "display_name": "Text Embedding 004" + }, + { + "id": "models/gemini-embedding-exp-03-07", + "object": "model", + "owned_by": "google", + "display_name": "Gemini Embedding Experimental 03-07" + }, + { + "id": "models/gemini-embedding-exp", + "object": "model", + "owned_by": "google", + "display_name": "Gemini Embedding Experimental" + }, + { + "id": "models/gemini-embedding-001", + "object": "model", + "owned_by": "google", + "display_name": "Gemini Embedding 001" + }, + { + "id": "models/aqa", + "object": "model", + "owned_by": "google", + "display_name": "Model that performs Attributed Question Answering." + }, + { + "id": "models/imagen-4.0-generate-preview-06-06", + "object": "model", + "owned_by": "google", + "display_name": "Imagen 4 (Preview)" + }, + { + "id": "models/imagen-4.0-ultra-generate-preview-06-06", + "object": "model", + "owned_by": "google", + "display_name": "Imagen 4 Ultra (Preview)" + }, + { + "id": "models/imagen-4.0-generate-001", + "object": "model", + "owned_by": "google", + "display_name": "Imagen 4" + }, + { + "id": "models/imagen-4.0-ultra-generate-001", + "object": "model", + "owned_by": "google", + "display_name": "Imagen 4 Ultra" + }, + { + "id": "models/imagen-4.0-fast-generate-001", + "object": "model", + "owned_by": "google", + "display_name": "Imagen 4 Fast" + }, + { + "id": "models/veo-2.0-generate-001", + "object": "model", + "owned_by": "google", + "display_name": "Veo 2" + }, + { + "id": "models/veo-3.0-generate-001", + "object": "model", + "owned_by": "google", + "display_name": "Veo 3" + }, + { + "id": "models/veo-3.0-fast-generate-001", + "object": "model", + "owned_by": "google", + "display_name": "Veo 3 fast" + }, + { + "id": "models/veo-3.1-generate-preview", + "object": "model", + "owned_by": "google", + "display_name": "Veo 3.1" + }, + { + "id": "models/veo-3.1-fast-generate-preview", + "object": "model", + "owned_by": "google", + "display_name": "Veo 3.1 fast" + }, + { + "id": "models/gemini-2.5-flash-native-audio-latest", + "object": "model", + "owned_by": "google", + "display_name": "Gemini 2.5 Flash Native Audio Latest" + }, + { + "id": "models/gemini-2.5-flash-native-audio-preview-09-2025", + "object": "model", + "owned_by": "google", + "display_name": "Gemini 2.5 Flash Native Audio Preview 09-2025" + }, + { + "id": "models/gemini-2.5-flash-native-audio-preview-12-2025", + "object": "model", + "owned_by": "google", + "display_name": "Gemini 2.5 Flash Native Audio Preview 12-2025" + }, + { + "id": "models/lyria-realtime-exp", + "object": "model", + "owned_by": "google", + "display_name": "Lyria Realtime Experimental" + } + ] +} diff --git a/tests/contract/testdata/groq/chat_completion.json b/tests/contract/testdata/groq/chat_completion.json new file mode 100644 index 00000000..82fea943 --- /dev/null +++ b/tests/contract/testdata/groq/chat_completion.json @@ -0,0 +1,33 @@ +{ + "id": "chatcmpl-704f97d8-0ee9-4fa6-b74f-777917cc07d7", + "object": "chat.completion", + "created": 1769093741, + "model": "llama-3.3-70b-versatile", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": "Hello World!" + }, + "logprobs": null, + "finish_reason": "stop" + } + ], + "usage": { + "queue_time": 0.089910694, + "prompt_tokens": 38, + "prompt_time": 0.001689387, + "completion_tokens": 4, + "completion_time": 0.030590271, + "total_tokens": 42, + "total_time": 0.032279658 + }, + "usage_breakdown": null, + "system_fingerprint": "fp_c06d5113ec", + "x_groq": { + "id": "req_01kfk38f6bebmb6c6xstpckt0x", + "seed": 874043000 + }, + "service_tier": "on_demand" +} diff --git a/tests/contract/testdata/groq/chat_completion_stream.txt b/tests/contract/testdata/groq/chat_completion_stream.txt new file mode 100644 index 00000000..17a56700 --- /dev/null +++ b/tests/contract/testdata/groq/chat_completion_stream.txt @@ -0,0 +1,11 @@ +data: {"id":"chatcmpl-bc4c2b5f-ad1c-4152-831f-a0885de968a3","object":"chat.completion.chunk","created":1769093744,"model":"llama-3.3-70b-versatile","system_fingerprint":"fp_c06d5113ec","choices":[{"index":0,"delta":{"role":"assistant","content":""},"logprobs":null,"finish_reason":null}],"x_groq":{"id":"req_01kfk38ha1ey6bw3b68mcaeddg","seed":2093329955}} + +data: {"id":"chatcmpl-bc4c2b5f-ad1c-4152-831f-a0885de968a3","object":"chat.completion.chunk","created":1769093744,"model":"llama-3.3-70b-versatile","system_fingerprint":"fp_c06d5113ec","choices":[{"index":0,"delta":{"content":"Hello"},"logprobs":null,"finish_reason":null}]} + +data: {"id":"chatcmpl-bc4c2b5f-ad1c-4152-831f-a0885de968a3","object":"chat.completion.chunk","created":1769093744,"model":"llama-3.3-70b-versatile","system_fingerprint":"fp_c06d5113ec","choices":[{"index":0,"delta":{"content":" World"},"logprobs":null,"finish_reason":null}]} + +data: {"id":"chatcmpl-bc4c2b5f-ad1c-4152-831f-a0885de968a3","object":"chat.completion.chunk","created":1769093744,"model":"llama-3.3-70b-versatile","system_fingerprint":"fp_c06d5113ec","choices":[{"index":0,"delta":{"content":"!"},"logprobs":null,"finish_reason":null}]} + +data: {"id":"chatcmpl-bc4c2b5f-ad1c-4152-831f-a0885de968a3","object":"chat.completion.chunk","created":1769093744,"model":"llama-3.3-70b-versatile","system_fingerprint":"fp_c06d5113ec","choices":[{"index":0,"delta":{},"logprobs":null,"finish_reason":"stop"}],"x_groq":{"id":"req_01kfk38ha1ey6bw3b68mcaeddg","usage":{"queue_time":0.090321431,"prompt_tokens":38,"prompt_time":0.003210785,"completion_tokens":4,"completion_time":0.027554055,"total_tokens":42,"total_time":0.03076484}},"usage":{"queue_time":0.090321431,"prompt_tokens":38,"prompt_time":0.003210785,"completion_tokens":4,"completion_time":0.027554055,"total_tokens":42,"total_time":0.03076484}} + +data: [DONE] diff --git a/tests/contract/testdata/groq/chat_with_params.json b/tests/contract/testdata/groq/chat_with_params.json new file mode 100644 index 00000000..a8e7812e --- /dev/null +++ b/tests/contract/testdata/groq/chat_with_params.json @@ -0,0 +1,33 @@ +{ + "id": "chatcmpl-3139ccda-4286-4f93-ad82-179db1171d29", + "object": "chat.completion", + "created": 1769093869, + "model": "llama-3.3-70b-versatile", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": "1. Earth\n2. Mars\n3. Jupiter" + }, + "logprobs": null, + "finish_reason": "stop" + } + ], + "usage": { + "queue_time": 0.04773597, + "prompt_tokens": 43, + "prompt_time": 0.002208827, + "completion_tokens": 12, + "completion_time": 0.023626468, + "total_tokens": 55, + "total_time": 0.025835295 + }, + "usage_breakdown": null, + "system_fingerprint": "fp_f8b414701e", + "x_groq": { + "id": "req_01kfk3cbj5epjvc8r2j89nb12x", + "seed": 975050826 + }, + "service_tier": "on_demand" +} diff --git a/tests/contract/testdata/groq/chat_with_tools.json b/tests/contract/testdata/groq/chat_with_tools.json new file mode 100644 index 00000000..b2fa5d77 --- /dev/null +++ b/tests/contract/testdata/groq/chat_with_tools.json @@ -0,0 +1,42 @@ +{ + "id": "chatcmpl-090b9776-6a9d-4ed8-8f75-8bf03dd0e90d", + "object": "chat.completion", + "created": 1769093871, + "model": "llama-3.3-70b-versatile", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "tool_calls": [ + { + "id": "2zvvry1t5", + "type": "function", + "function": { + "name": "get_weather", + "arguments": "{\"location\":\"London\"}" + } + } + ] + }, + "logprobs": null, + "finish_reason": "tool_calls" + } + ], + "usage": { + "queue_time": 0.092826763, + "prompt_tokens": 203, + "prompt_time": 0.019662953, + "completion_tokens": 14, + "completion_time": 0.055257456, + "total_tokens": 217, + "total_time": 0.074920409 + }, + "usage_breakdown": null, + "system_fingerprint": "fp_dae98b5ecb", + "x_groq": { + "id": "req_01kfk3cdtxfns85rqz3a5afpk9", + "seed": 1450173236 + }, + "service_tier": "on_demand" +} diff --git a/tests/contract/testdata/groq/models.json b/tests/contract/testdata/groq/models.json new file mode 100644 index 00000000..9f838bbc --- /dev/null +++ b/tests/contract/testdata/groq/models.json @@ -0,0 +1,205 @@ +{ + "object": "list", + "data": [ + { + "id": "canopylabs/orpheus-v1-english", + "object": "model", + "created": 1766186316, + "owned_by": "Canopy Labs", + "active": true, + "context_window": 4000, + "public_apps": null, + "max_completion_tokens": 50000 + }, + { + "id": "whisper-large-v3", + "object": "model", + "created": 1693721698, + "owned_by": "OpenAI", + "active": true, + "context_window": 448, + "public_apps": null, + "max_completion_tokens": 448 + }, + { + "id": "moonshotai/kimi-k2-instruct", + "object": "model", + "created": 1752435491, + "owned_by": "Moonshot AI", + "active": true, + "context_window": 131072, + "public_apps": null, + "max_completion_tokens": 16384 + }, + { + "id": "canopylabs/orpheus-arabic-saudi", + "object": "model", + "created": 1765926439, + "owned_by": "Canopy Labs", + "active": true, + "context_window": 4000, + "public_apps": null, + "max_completion_tokens": 50000 + }, + { + "id": "moonshotai/kimi-k2-instruct-0905", + "object": "model", + "created": 1757046093, + "owned_by": "Moonshot AI", + "active": true, + "context_window": 262144, + "public_apps": null, + "max_completion_tokens": 16384 + }, + { + "id": "meta-llama/llama-4-maverick-17b-128e-instruct", + "object": "model", + "created": 1743877158, + "owned_by": "Meta", + "active": true, + "context_window": 131072, + "public_apps": null, + "max_completion_tokens": 8192 + }, + { + "id": "openai/gpt-oss-20b", + "object": "model", + "created": 1754407957, + "owned_by": "OpenAI", + "active": true, + "context_window": 131072, + "public_apps": null, + "max_completion_tokens": 65536 + }, + { + "id": "meta-llama/llama-guard-4-12b", + "object": "model", + "created": 1746743847, + "owned_by": "Meta", + "active": true, + "context_window": 131072, + "public_apps": null, + "max_completion_tokens": 1024 + }, + { + "id": "llama-3.1-8b-instant", + "object": "model", + "created": 1693721698, + "owned_by": "Meta", + "active": true, + "context_window": 131072, + "public_apps": null, + "max_completion_tokens": 131072 + }, + { + "id": "meta-llama/llama-4-scout-17b-16e-instruct", + "object": "model", + "created": 1743874824, + "owned_by": "Meta", + "active": true, + "context_window": 131072, + "public_apps": null, + "max_completion_tokens": 8192 + }, + { + "id": "allam-2-7b", + "object": "model", + "created": 1737672203, + "owned_by": "SDAIA", + "active": true, + "context_window": 4096, + "public_apps": null, + "max_completion_tokens": 4096 + }, + { + "id": "groq/compound-mini", + "object": "model", + "created": 1756949707, + "owned_by": "Groq", + "active": true, + "context_window": 131072, + "public_apps": null, + "max_completion_tokens": 8192 + }, + { + "id": "whisper-large-v3-turbo", + "object": "model", + "created": 1728413088, + "owned_by": "OpenAI", + "active": true, + "context_window": 448, + "public_apps": null, + "max_completion_tokens": 448 + }, + { + "id": "qwen/qwen3-32b", + "object": "model", + "created": 1748396646, + "owned_by": "Alibaba Cloud", + "active": true, + "context_window": 131072, + "public_apps": null, + "max_completion_tokens": 40960 + }, + { + "id": "meta-llama/llama-prompt-guard-2-22m", + "object": "model", + "created": 1748632101, + "owned_by": "Meta", + "active": true, + "context_window": 512, + "public_apps": null, + "max_completion_tokens": 512 + }, + { + "id": "openai/gpt-oss-120b", + "object": "model", + "created": 1754408224, + "owned_by": "OpenAI", + "active": true, + "context_window": 131072, + "public_apps": null, + "max_completion_tokens": 65536 + }, + { + "id": "groq/compound", + "object": "model", + "created": 1756949530, + "owned_by": "Groq", + "active": true, + "context_window": 131072, + "public_apps": null, + "max_completion_tokens": 8192 + }, + { + "id": "meta-llama/llama-prompt-guard-2-86m", + "object": "model", + "created": 1748632165, + "owned_by": "Meta", + "active": true, + "context_window": 512, + "public_apps": null, + "max_completion_tokens": 512 + }, + { + "id": "openai/gpt-oss-safeguard-20b", + "object": "model", + "created": 1761708789, + "owned_by": "OpenAI", + "active": true, + "context_window": 131072, + "public_apps": null, + "max_completion_tokens": 65536 + }, + { + "id": "llama-3.3-70b-versatile", + "object": "model", + "created": 1733447754, + "owned_by": "Meta", + "active": true, + "context_window": 131072, + "public_apps": null, + "max_completion_tokens": 32768 + } + ] +} diff --git a/tests/contract/testdata/openai/chat_completion.json b/tests/contract/testdata/openai/chat_completion.json new file mode 100644 index 00000000..af416a4e --- /dev/null +++ b/tests/contract/testdata/openai/chat_completion.json @@ -0,0 +1,36 @@ +{ + "id": "chatcmpl-D0q7lRI0Z2q8190Q0ue3JnnWtqLrd", + "object": "chat.completion", + "created": 1769092737, + "model": "gpt-4o-mini-2024-07-18", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": "Hello, World!", + "refusal": null, + "annotations": [] + }, + "logprobs": null, + "finish_reason": "stop" + } + ], + "usage": { + "prompt_tokens": 17, + "completion_tokens": 4, + "total_tokens": 21, + "prompt_tokens_details": { + "cached_tokens": 0, + "audio_tokens": 0 + }, + "completion_tokens_details": { + "reasoning_tokens": 0, + "audio_tokens": 0, + "accepted_prediction_tokens": 0, + "rejected_prediction_tokens": 0 + } + }, + "service_tier": "default", + "system_fingerprint": "fp_29330a9688" +} diff --git a/tests/contract/testdata/openai/chat_completion_reasoning.json b/tests/contract/testdata/openai/chat_completion_reasoning.json new file mode 100644 index 00000000..93262c24 --- /dev/null +++ b/tests/contract/testdata/openai/chat_completion_reasoning.json @@ -0,0 +1,35 @@ +{ + "id": "chatcmpl-D0qOCYH6prj5BCFQQgEZUHOwVyVpo", + "object": "chat.completion", + "created": 1769093756, + "model": "o3-mini-2025-01-31", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": "2 + 2 equals 4.", + "refusal": null, + "annotations": [] + }, + "finish_reason": "stop" + } + ], + "usage": { + "prompt_tokens": 13, + "completion_tokens": 85, + "total_tokens": 98, + "prompt_tokens_details": { + "cached_tokens": 0, + "audio_tokens": 0 + }, + "completion_tokens_details": { + "reasoning_tokens": 64, + "audio_tokens": 0, + "accepted_prediction_tokens": 0, + "rejected_prediction_tokens": 0 + } + }, + "service_tier": "default", + "system_fingerprint": "fp_d48b29c73d" +} diff --git a/tests/contract/testdata/openai/chat_completion_stream.txt b/tests/contract/testdata/openai/chat_completion_stream.txt new file mode 100644 index 00000000..9b26f6c8 --- /dev/null +++ b/tests/contract/testdata/openai/chat_completion_stream.txt @@ -0,0 +1,13 @@ +data: {"id":"chatcmpl-D0q8CZXN1uEzngZGkU7FuV92lnXWq","object":"chat.completion.chunk","created":1769092764,"model":"gpt-4o-mini-2024-07-18","service_tier":"default","system_fingerprint":"fp_29330a9688","choices":[{"index":0,"delta":{"role":"assistant","content":"","refusal":null},"logprobs":null,"finish_reason":null}],"obfuscation":"iPgRzm"} + +data: {"id":"chatcmpl-D0q8CZXN1uEzngZGkU7FuV92lnXWq","object":"chat.completion.chunk","created":1769092764,"model":"gpt-4o-mini-2024-07-18","service_tier":"default","system_fingerprint":"fp_29330a9688","choices":[{"index":0,"delta":{"content":"Hello"},"logprobs":null,"finish_reason":null}],"obfuscation":"ZGF"} + +data: {"id":"chatcmpl-D0q8CZXN1uEzngZGkU7FuV92lnXWq","object":"chat.completion.chunk","created":1769092764,"model":"gpt-4o-mini-2024-07-18","service_tier":"default","system_fingerprint":"fp_29330a9688","choices":[{"index":0,"delta":{"content":","},"logprobs":null,"finish_reason":null}],"obfuscation":"lRK2aBv"} + +data: {"id":"chatcmpl-D0q8CZXN1uEzngZGkU7FuV92lnXWq","object":"chat.completion.chunk","created":1769092764,"model":"gpt-4o-mini-2024-07-18","service_tier":"default","system_fingerprint":"fp_29330a9688","choices":[{"index":0,"delta":{"content":" World"},"logprobs":null,"finish_reason":null}],"obfuscation":"kJ"} + +data: {"id":"chatcmpl-D0q8CZXN1uEzngZGkU7FuV92lnXWq","object":"chat.completion.chunk","created":1769092764,"model":"gpt-4o-mini-2024-07-18","service_tier":"default","system_fingerprint":"fp_29330a9688","choices":[{"index":0,"delta":{"content":"!"},"logprobs":null,"finish_reason":null}],"obfuscation":"0wmdEAC"} + +data: {"id":"chatcmpl-D0q8CZXN1uEzngZGkU7FuV92lnXWq","object":"chat.completion.chunk","created":1769092764,"model":"gpt-4o-mini-2024-07-18","service_tier":"default","system_fingerprint":"fp_29330a9688","choices":[{"index":0,"delta":{},"logprobs":null,"finish_reason":"stop"}],"obfuscation":"pH"} + +data: [DONE] diff --git a/tests/contract/testdata/openai/chat_json_mode.json b/tests/contract/testdata/openai/chat_json_mode.json new file mode 100644 index 00000000..2ebb4fce --- /dev/null +++ b/tests/contract/testdata/openai/chat_json_mode.json @@ -0,0 +1,36 @@ +{ + "id": "chatcmpl-D0qOi0MwZaBZCRsRrfvuq22n8CqeZ", + "object": "chat.completion", + "created": 1769093788, + "model": "gpt-4o-mini-2024-07-18", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": "{\"colors\": [\"red\", \"green\", \"blue\"]}", + "refusal": null, + "annotations": [] + }, + "logprobs": null, + "finish_reason": "stop" + } + ], + "usage": { + "prompt_tokens": 14, + "completion_tokens": 13, + "total_tokens": 27, + "prompt_tokens_details": { + "cached_tokens": 0, + "audio_tokens": 0 + }, + "completion_tokens_details": { + "reasoning_tokens": 0, + "audio_tokens": 0, + "accepted_prediction_tokens": 0, + "rejected_prediction_tokens": 0 + } + }, + "service_tier": "default", + "system_fingerprint": "fp_29330a9688" +} diff --git a/tests/contract/testdata/openai/chat_multi_turn.json b/tests/contract/testdata/openai/chat_multi_turn.json new file mode 100644 index 00000000..5a1212a6 --- /dev/null +++ b/tests/contract/testdata/openai/chat_multi_turn.json @@ -0,0 +1,36 @@ +{ + "id": "chatcmpl-D0qOykS3WniK2xU97yFf96Aa4Me8d", + "object": "chat.completion", + "created": 1769093804, + "model": "gpt-4o-mini-2024-07-18", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": "If you add 2 more to 8, you get 10.", + "refusal": null, + "annotations": [] + }, + "logprobs": null, + "finish_reason": "stop" + } + ], + "usage": { + "prompt_tokens": 47, + "completion_tokens": 15, + "total_tokens": 62, + "prompt_tokens_details": { + "cached_tokens": 0, + "audio_tokens": 0 + }, + "completion_tokens_details": { + "reasoning_tokens": 0, + "audio_tokens": 0, + "accepted_prediction_tokens": 0, + "rejected_prediction_tokens": 0 + } + }, + "service_tier": "default", + "system_fingerprint": "fp_29330a9688" +} diff --git a/tests/contract/testdata/openai/chat_multimodal.json b/tests/contract/testdata/openai/chat_multimodal.json new file mode 100644 index 00000000..93ef3cea --- /dev/null +++ b/tests/contract/testdata/openai/chat_multimodal.json @@ -0,0 +1,36 @@ +{ + "id": "chatcmpl-D0qUlBEghKIASKSVRE4fnjs5QUDGF", + "object": "chat.completion", + "created": 1769094163, + "model": "gpt-4o-mini-2024-07-18", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": "The image includes the following colors:\n\n- Blue\n- Red\n- Yellow\n- Green\n\nThese colors are combined in the logo design.", + "refusal": null, + "annotations": [] + }, + "logprobs": null, + "finish_reason": "stop" + } + ], + "usage": { + "prompt_tokens": 14183, + "completion_tokens": 28, + "total_tokens": 14211, + "prompt_tokens_details": { + "cached_tokens": 0, + "audio_tokens": 0 + }, + "completion_tokens_details": { + "reasoning_tokens": 0, + "audio_tokens": 0, + "accepted_prediction_tokens": 0, + "rejected_prediction_tokens": 0 + } + }, + "service_tier": "default", + "system_fingerprint": "fp_8bbc38b4db" +} diff --git a/tests/contract/testdata/openai/chat_with_params.json b/tests/contract/testdata/openai/chat_with_params.json new file mode 100644 index 00000000..a1aea703 --- /dev/null +++ b/tests/contract/testdata/openai/chat_with_params.json @@ -0,0 +1,36 @@ +{ + "id": "chatcmpl-D0qOmQKlnUcfmQ1UR9UNvcYikTjhe", + "object": "chat.completion", + "created": 1769093792, + "model": "gpt-4o-mini-2024-07-18", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": "1, 2, 3, 4, ", + "refusal": null, + "annotations": [] + }, + "logprobs": null, + "finish_reason": "stop" + } + ], + "usage": { + "prompt_tokens": 27, + "completion_tokens": 13, + "total_tokens": 40, + "prompt_tokens_details": { + "cached_tokens": 0, + "audio_tokens": 0 + }, + "completion_tokens_details": { + "reasoning_tokens": 0, + "audio_tokens": 0, + "accepted_prediction_tokens": 0, + "rejected_prediction_tokens": 0 + } + }, + "service_tier": "default", + "system_fingerprint": "fp_29330a9688" +} diff --git a/tests/contract/testdata/openai/chat_with_tools.json b/tests/contract/testdata/openai/chat_with_tools.json new file mode 100644 index 00000000..72c85a0c --- /dev/null +++ b/tests/contract/testdata/openai/chat_with_tools.json @@ -0,0 +1,46 @@ +{ + "id": "chatcmpl-D0qOd6qK1YftNTsl3IZtKWnHELm8g", + "object": "chat.completion", + "created": 1769093783, + "model": "gpt-4o-mini-2024-07-18", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": null, + "tool_calls": [ + { + "id": "call_X8zi23yeE70r4qiZ2lhIuRNK", + "type": "function", + "function": { + "name": "get_weather", + "arguments": "{\"location\":\"Paris\"}" + } + } + ], + "refusal": null, + "annotations": [] + }, + "logprobs": null, + "finish_reason": "tool_calls" + } + ], + "usage": { + "prompt_tokens": 47, + "completion_tokens": 14, + "total_tokens": 61, + "prompt_tokens_details": { + "cached_tokens": 0, + "audio_tokens": 0 + }, + "completion_tokens_details": { + "reasoning_tokens": 0, + "audio_tokens": 0, + "accepted_prediction_tokens": 0, + "rejected_prediction_tokens": 0 + } + }, + "service_tier": "default", + "system_fingerprint": "fp_29330a9688" +} diff --git a/tests/contract/testdata/openai/models.json b/tests/contract/testdata/openai/models.json new file mode 100644 index 00000000..217d836f --- /dev/null +++ b/tests/contract/testdata/openai/models.json @@ -0,0 +1,719 @@ +{ + "object": "list", + "data": [ + { + "id": "gpt-4-0613", + "object": "model", + "created": 1686588896, + "owned_by": "openai" + }, + { + "id": "gpt-4", + "object": "model", + "created": 1687882411, + "owned_by": "openai" + }, + { + "id": "gpt-3.5-turbo", + "object": "model", + "created": 1677610602, + "owned_by": "openai" + }, + { + "id": "gpt-5.2-codex", + "object": "model", + "created": 1766164985, + "owned_by": "system" + }, + { + "id": "gpt-4o-mini-tts-2025-12-15", + "object": "model", + "created": 1765610837, + "owned_by": "system" + }, + { + "id": "gpt-realtime-mini-2025-12-15", + "object": "model", + "created": 1765612007, + "owned_by": "system" + }, + { + "id": "gpt-audio-mini-2025-12-15", + "object": "model", + "created": 1765760008, + "owned_by": "system" + }, + { + "id": "chatgpt-image-latest", + "object": "model", + "created": 1765925279, + "owned_by": "system" + }, + { + "id": "davinci-002", + "object": "model", + "created": 1692634301, + "owned_by": "system" + }, + { + "id": "babbage-002", + "object": "model", + "created": 1692634615, + "owned_by": "system" + }, + { + "id": "gpt-3.5-turbo-instruct", + "object": "model", + "created": 1692901427, + "owned_by": "system" + }, + { + "id": "gpt-3.5-turbo-instruct-0914", + "object": "model", + "created": 1694122472, + "owned_by": "system" + }, + { + "id": "dall-e-3", + "object": "model", + "created": 1698785189, + "owned_by": "system" + }, + { + "id": "dall-e-2", + "object": "model", + "created": 1698798177, + "owned_by": "system" + }, + { + "id": "gpt-4-1106-preview", + "object": "model", + "created": 1698957206, + "owned_by": "system" + }, + { + "id": "gpt-3.5-turbo-1106", + "object": "model", + "created": 1698959748, + "owned_by": "system" + }, + { + "id": "tts-1-hd", + "object": "model", + "created": 1699046015, + "owned_by": "system" + }, + { + "id": "tts-1-1106", + "object": "model", + "created": 1699053241, + "owned_by": "system" + }, + { + "id": "tts-1-hd-1106", + "object": "model", + "created": 1699053533, + "owned_by": "system" + }, + { + "id": "text-embedding-3-small", + "object": "model", + "created": 1705948997, + "owned_by": "system" + }, + { + "id": "text-embedding-3-large", + "object": "model", + "created": 1705953180, + "owned_by": "system" + }, + { + "id": "gpt-4-0125-preview", + "object": "model", + "created": 1706037612, + "owned_by": "system" + }, + { + "id": "gpt-4-turbo-preview", + "object": "model", + "created": 1706037777, + "owned_by": "system" + }, + { + "id": "gpt-3.5-turbo-0125", + "object": "model", + "created": 1706048358, + "owned_by": "system" + }, + { + "id": "gpt-4-turbo", + "object": "model", + "created": 1712361441, + "owned_by": "system" + }, + { + "id": "gpt-4-turbo-2024-04-09", + "object": "model", + "created": 1712601677, + "owned_by": "system" + }, + { + "id": "gpt-4o", + "object": "model", + "created": 1715367049, + "owned_by": "system" + }, + { + "id": "gpt-4o-2024-05-13", + "object": "model", + "created": 1715368132, + "owned_by": "system" + }, + { + "id": "gpt-4o-mini-2024-07-18", + "object": "model", + "created": 1721172717, + "owned_by": "system" + }, + { + "id": "gpt-4o-mini", + "object": "model", + "created": 1721172741, + "owned_by": "system" + }, + { + "id": "gpt-4o-2024-08-06", + "object": "model", + "created": 1722814719, + "owned_by": "system" + }, + { + "id": "chatgpt-4o-latest", + "object": "model", + "created": 1723515131, + "owned_by": "system" + }, + { + "id": "gpt-4o-audio-preview", + "object": "model", + "created": 1727460443, + "owned_by": "system" + }, + { + "id": "gpt-4o-realtime-preview", + "object": "model", + "created": 1727659998, + "owned_by": "system" + }, + { + "id": "omni-moderation-latest", + "object": "model", + "created": 1731689265, + "owned_by": "system" + }, + { + "id": "omni-moderation-2024-09-26", + "object": "model", + "created": 1732734466, + "owned_by": "system" + }, + { + "id": "gpt-4o-realtime-preview-2024-12-17", + "object": "model", + "created": 1733945430, + "owned_by": "system" + }, + { + "id": "gpt-4o-audio-preview-2024-12-17", + "object": "model", + "created": 1734034239, + "owned_by": "system" + }, + { + "id": "gpt-4o-mini-realtime-preview-2024-12-17", + "object": "model", + "created": 1734112601, + "owned_by": "system" + }, + { + "id": "gpt-4o-mini-audio-preview-2024-12-17", + "object": "model", + "created": 1734115920, + "owned_by": "system" + }, + { + "id": "o1-2024-12-17", + "object": "model", + "created": 1734326976, + "owned_by": "system" + }, + { + "id": "o1", + "object": "model", + "created": 1734375816, + "owned_by": "system" + }, + { + "id": "gpt-4o-mini-realtime-preview", + "object": "model", + "created": 1734387380, + "owned_by": "system" + }, + { + "id": "gpt-4o-mini-audio-preview", + "object": "model", + "created": 1734387424, + "owned_by": "system" + }, + { + "id": "o3-mini", + "object": "model", + "created": 1737146383, + "owned_by": "system" + }, + { + "id": "o3-mini-2025-01-31", + "object": "model", + "created": 1738010200, + "owned_by": "system" + }, + { + "id": "gpt-4o-2024-11-20", + "object": "model", + "created": 1739331543, + "owned_by": "system" + }, + { + "id": "gpt-4o-search-preview-2025-03-11", + "object": "model", + "created": 1741388170, + "owned_by": "system" + }, + { + "id": "gpt-4o-search-preview", + "object": "model", + "created": 1741388720, + "owned_by": "system" + }, + { + "id": "gpt-4o-mini-search-preview-2025-03-11", + "object": "model", + "created": 1741390858, + "owned_by": "system" + }, + { + "id": "gpt-4o-mini-search-preview", + "object": "model", + "created": 1741391161, + "owned_by": "system" + }, + { + "id": "gpt-4o-transcribe", + "object": "model", + "created": 1742068463, + "owned_by": "system" + }, + { + "id": "gpt-4o-mini-transcribe", + "object": "model", + "created": 1742068596, + "owned_by": "system" + }, + { + "id": "o1-pro-2025-03-19", + "object": "model", + "created": 1742251504, + "owned_by": "system" + }, + { + "id": "o1-pro", + "object": "model", + "created": 1742251791, + "owned_by": "system" + }, + { + "id": "gpt-4o-mini-tts", + "object": "model", + "created": 1742403959, + "owned_by": "system" + }, + { + "id": "o3-2025-04-16", + "object": "model", + "created": 1744133301, + "owned_by": "system" + }, + { + "id": "o4-mini-2025-04-16", + "object": "model", + "created": 1744133506, + "owned_by": "system" + }, + { + "id": "o3", + "object": "model", + "created": 1744225308, + "owned_by": "system" + }, + { + "id": "o4-mini", + "object": "model", + "created": 1744225351, + "owned_by": "system" + }, + { + "id": "gpt-4.1-2025-04-14", + "object": "model", + "created": 1744315746, + "owned_by": "system" + }, + { + "id": "gpt-4.1", + "object": "model", + "created": 1744316542, + "owned_by": "system" + }, + { + "id": "gpt-4.1-mini-2025-04-14", + "object": "model", + "created": 1744317547, + "owned_by": "system" + }, + { + "id": "gpt-4.1-mini", + "object": "model", + "created": 1744318173, + "owned_by": "system" + }, + { + "id": "gpt-4.1-nano-2025-04-14", + "object": "model", + "created": 1744321025, + "owned_by": "system" + }, + { + "id": "gpt-4.1-nano", + "object": "model", + "created": 1744321707, + "owned_by": "system" + }, + { + "id": "gpt-image-1", + "object": "model", + "created": 1745517030, + "owned_by": "system" + }, + { + "id": "codex-mini-latest", + "object": "model", + "created": 1746673257, + "owned_by": "system" + }, + { + "id": "o3-pro", + "object": "model", + "created": 1748475349, + "owned_by": "system" + }, + { + "id": "gpt-4o-realtime-preview-2025-06-03", + "object": "model", + "created": 1748907838, + "owned_by": "system" + }, + { + "id": "gpt-4o-audio-preview-2025-06-03", + "object": "model", + "created": 1748908498, + "owned_by": "system" + }, + { + "id": "o3-pro-2025-06-10", + "object": "model", + "created": 1749166761, + "owned_by": "system" + }, + { + "id": "o4-mini-deep-research", + "object": "model", + "created": 1749685485, + "owned_by": "system" + }, + { + "id": "o3-deep-research", + "object": "model", + "created": 1749840121, + "owned_by": "system" + }, + { + "id": "gpt-4o-transcribe-diarize", + "object": "model", + "created": 1750798887, + "owned_by": "system" + }, + { + "id": "o3-deep-research-2025-06-26", + "object": "model", + "created": 1750865219, + "owned_by": "system" + }, + { + "id": "o4-mini-deep-research-2025-06-26", + "object": "model", + "created": 1750866121, + "owned_by": "system" + }, + { + "id": "gpt-5-chat-latest", + "object": "model", + "created": 1754073306, + "owned_by": "system" + }, + { + "id": "gpt-5-2025-08-07", + "object": "model", + "created": 1754075360, + "owned_by": "system" + }, + { + "id": "gpt-5", + "object": "model", + "created": 1754425777, + "owned_by": "system" + }, + { + "id": "gpt-5-mini-2025-08-07", + "object": "model", + "created": 1754425867, + "owned_by": "system" + }, + { + "id": "gpt-5-mini", + "object": "model", + "created": 1754425928, + "owned_by": "system" + }, + { + "id": "gpt-5-nano-2025-08-07", + "object": "model", + "created": 1754426303, + "owned_by": "system" + }, + { + "id": "gpt-5-nano", + "object": "model", + "created": 1754426384, + "owned_by": "system" + }, + { + "id": "gpt-audio-2025-08-28", + "object": "model", + "created": 1756256146, + "owned_by": "system" + }, + { + "id": "gpt-realtime", + "object": "model", + "created": 1756271701, + "owned_by": "system" + }, + { + "id": "gpt-realtime-2025-08-28", + "object": "model", + "created": 1756271773, + "owned_by": "system" + }, + { + "id": "gpt-audio", + "object": "model", + "created": 1756339249, + "owned_by": "system" + }, + { + "id": "gpt-5-codex", + "object": "model", + "created": 1757527818, + "owned_by": "system" + }, + { + "id": "gpt-image-1-mini", + "object": "model", + "created": 1758845821, + "owned_by": "system" + }, + { + "id": "gpt-5-pro-2025-10-06", + "object": "model", + "created": 1759469707, + "owned_by": "system" + }, + { + "id": "gpt-5-pro", + "object": "model", + "created": 1759469822, + "owned_by": "system" + }, + { + "id": "gpt-audio-mini", + "object": "model", + "created": 1759512027, + "owned_by": "system" + }, + { + "id": "gpt-audio-mini-2025-10-06", + "object": "model", + "created": 1759512137, + "owned_by": "system" + }, + { + "id": "gpt-5-search-api", + "object": "model", + "created": 1759514629, + "owned_by": "system" + }, + { + "id": "gpt-realtime-mini", + "object": "model", + "created": 1759517133, + "owned_by": "system" + }, + { + "id": "gpt-realtime-mini-2025-10-06", + "object": "model", + "created": 1759517175, + "owned_by": "system" + }, + { + "id": "sora-2", + "object": "model", + "created": 1759708615, + "owned_by": "system" + }, + { + "id": "sora-2-pro", + "object": "model", + "created": 1759708663, + "owned_by": "system" + }, + { + "id": "gpt-5-search-api-2025-10-14", + "object": "model", + "created": 1760043960, + "owned_by": "system" + }, + { + "id": "gpt-5.1-chat-latest", + "object": "model", + "created": 1762547951, + "owned_by": "system" + }, + { + "id": "gpt-5.1-2025-11-13", + "object": "model", + "created": 1762800353, + "owned_by": "system" + }, + { + "id": "gpt-5.1", + "object": "model", + "created": 1762800673, + "owned_by": "system" + }, + { + "id": "gpt-5.1-codex", + "object": "model", + "created": 1762988221, + "owned_by": "system" + }, + { + "id": "gpt-5.1-codex-mini", + "object": "model", + "created": 1763007109, + "owned_by": "system" + }, + { + "id": "gpt-5.1-codex-max", + "object": "model", + "created": 1763671532, + "owned_by": "system" + }, + { + "id": "gpt-image-1.5", + "object": "model", + "created": 1764030620, + "owned_by": "system" + }, + { + "id": "gpt-5.2-2025-12-11", + "object": "model", + "created": 1765313028, + "owned_by": "system" + }, + { + "id": "gpt-5.2", + "object": "model", + "created": 1765313051, + "owned_by": "system" + }, + { + "id": "gpt-5.2-pro-2025-12-11", + "object": "model", + "created": 1765343959, + "owned_by": "system" + }, + { + "id": "gpt-5.2-pro", + "object": "model", + "created": 1765343983, + "owned_by": "system" + }, + { + "id": "gpt-5.2-chat-latest", + "object": "model", + "created": 1765344352, + "owned_by": "system" + }, + { + "id": "gpt-4o-mini-transcribe-2025-12-15", + "object": "model", + "created": 1765610407, + "owned_by": "system" + }, + { + "id": "gpt-4o-mini-transcribe-2025-03-20", + "object": "model", + "created": 1765610545, + "owned_by": "system" + }, + { + "id": "gpt-4o-mini-tts-2025-03-20", + "object": "model", + "created": 1765610731, + "owned_by": "system" + }, + { + "id": "gpt-3.5-turbo-16k", + "object": "model", + "created": 1683758102, + "owned_by": "openai-internal" + }, + { + "id": "tts-1", + "object": "model", + "created": 1681940951, + "owned_by": "openai-internal" + }, + { + "id": "whisper-1", + "object": "model", + "created": 1677532384, + "owned_by": "openai-internal" + }, + { + "id": "text-embedding-ada-002", + "object": "model", + "created": 1671217299, + "owned_by": "openai-internal" + } + ] +} diff --git a/tests/contract/testdata/xai/chat_completion.json b/tests/contract/testdata/xai/chat_completion.json new file mode 100644 index 00000000..0dce6b73 --- /dev/null +++ b/tests/contract/testdata/xai/chat_completion.json @@ -0,0 +1,38 @@ +{ + "id": "2ac1119b-8e3f-969e-1c85-9a6dd6d91720", + "object": "chat.completion", + "created": 1769093901, + "model": "grok-3-mini", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": "Hello World! 😊\n\nIf that's a reference to the classic programming example, I'm happy to help with any coding questions or more. What else can I assist you with?", + "reasoning_content": "First, the user said: \"Say Hello World\". That seems like a straightforward request. It's similar to the classic \"Hello, World!\" program in programming, which is often the first thing people learn.\n\nMy role is to be a helpful and engaging AI assistant. So, I should respond in a friendly and appropriate manner.\n\nPossible interpretations:\n- They might literally want me to say \"Hello World\".\n- It could be a test or a simple command to see if I'm working.\n- Or, it might be a misspelling or shorthand for \"Hello, World!\", which is a common phrase.\n\nIn programming contexts, \"Hello World\" is a program that outputs that string. Since I'm an AI, I can just output it directly.\n\nTo make it more engaging, I could add a bit of personality. For example, respond with \"Hello, World!\" and maybe follow up with something like \"How can I help you today?\" to keep the conversation going.\n\nKeep it simple: The user might just want a direct response.\n\nEnsure my response is accurate and not misleading. The user said \"Say Hello World\", not \"Say Hello, World\". There's no comma in their message, but in standard English, it's \"Hello, World!\" with a comma.\n\nI should mirror what they said closely. So, perhaps say \"Hello World\" without the comma.\n\nBest practice: In AI responses, it's good to be precise. But for greetings, \"Hello, World!\" is more conventional.\n\nFinally, structure my response:\n- Start with the requested phrase.\n- Add a friendly touch if appropriate.\n\nResponse idea:\n- \"Hello World!\"\n- Or, \"Sure, here's Hello World: Hello, World!\"\n- To be more interactive: \"Hello World! Is there anything else you'd like me to do?\"\n\nSince this is likely a simple command, I'll go with a direct response.", + "refusal": null + }, + "finish_reason": "stop" + } + ], + "usage": { + "prompt_tokens": 10, + "completion_tokens": 35, + "total_tokens": 420, + "prompt_tokens_details": { + "text_tokens": 10, + "audio_tokens": 0, + "image_tokens": 0, + "cached_tokens": 3 + }, + "completion_tokens_details": { + "reasoning_tokens": 375, + "audio_tokens": 0, + "accepted_prediction_tokens": 0, + "rejected_prediction_tokens": 0 + }, + "num_sources_used": 0, + "cost_in_usd_ticks": 2073250 + }, + "system_fingerprint": "fp_2a885414fb" +} diff --git a/tests/contract/testdata/xai/chat_completion_stream.txt b/tests/contract/testdata/xai/chat_completion_stream.txt new file mode 100644 index 00000000..e8098cbc --- /dev/null +++ b/tests/contract/testdata/xai/chat_completion_stream.txt @@ -0,0 +1,587 @@ +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093906,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":"First","role":"assistant"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093906,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":","}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093906,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" the"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093906,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" user"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093906,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" said"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093906,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":":"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093906,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" \""}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093906,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":"Say"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093906,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" Hello"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093906,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" World"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093906,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":"\"."}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093906,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" This"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093906,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" seems"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093906,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" like"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093906,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" a"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093906,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" straightforward"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093906,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" request"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093906,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":"."}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093906,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" They"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093906,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" probably"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093906,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" want"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093906,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" me"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093906,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" to"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093906,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" output"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093906,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" the"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093906,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" phrase"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093906,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" \""}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093906,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":"Hello"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093906,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" World"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093906,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":"\".\n\n"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093906,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":"But"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093906,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" let's"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093906,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" make"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093906,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" sure"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093906,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":"."}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093906,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" \""}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093906,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":"Hello"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093906,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" World"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093906,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":"\""}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093906,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" is"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093906,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" a"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093906,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" classic"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093906,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" phrase"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093906,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" used"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093906,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" in"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093906,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" programming"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093906,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" tutorials"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093906,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":","}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093906,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" like"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093906,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" the"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093906,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" first"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093906,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" program"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093906,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" you"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093906,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" write"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093906,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" in"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093906,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" many"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093906,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" languages"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093906,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":"."}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093906,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" So"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093906,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":","}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093906,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" they"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093906,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" might"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093906,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" be"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093906,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" testing"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093906,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" me"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093906,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" or"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093906,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" just"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093906,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" being"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093906,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" playful"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093906,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":".\n\n"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093906,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":"My"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093906,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" role"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093906,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" is"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093906,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" to"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093906,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" be"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093906,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" helpful"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093906,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" and"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093906,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" engaging"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093906,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":"."}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093906,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" As"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093906,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" an"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093906,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" AI"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093906,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" assistant"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093906,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":","}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093907,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" I"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093907,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" should"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093907,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" respond"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093907,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" in"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093907,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" a"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093907,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" friendly"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093907,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" manner"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093907,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":".\n\n"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093907,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":"Possible"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093907,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" ways"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093907,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" to"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093907,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" respond"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093907,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":":\n\n"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093907,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":"1"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093907,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":"."}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093907,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" Simply"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093907,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" say"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093907,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" \""}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093907,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":"Hello"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093907,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" World"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093907,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":"\""}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093907,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" as"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093907,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" requested"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093907,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":".\n\n"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093907,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":"2"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093907,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":"."}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093907,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" Add"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093907,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" a"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093907,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" bit"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093907,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" more"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093907,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" to"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093907,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" make"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093907,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" it"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093907,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" conversational"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093907,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":","}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093907,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" like"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093907,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" \""}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093907,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":"Sure"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093907,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":","}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093907,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" here's"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093907,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" '"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093907,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":"Hello"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093907,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" World"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093907,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":"'"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093907,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":"!\""}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093907,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" or"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093907,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" \""}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093907,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":"Hello"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093907,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" World"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093907,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":"!"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093907,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" How"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093907,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" can"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093907,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" I"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093907,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" assist"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093907,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" you"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093907,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" further"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093907,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":"?\"\n\n"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093907,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":"3"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093907,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":"."}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093907,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" If"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093907,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" it's"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093907,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" a"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093907,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" programming"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093907,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" reference"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093907,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":","}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093907,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" I"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093907,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" could"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093907,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" tie"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093907,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" it"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093907,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" back"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093907,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" to"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093907,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" that"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093907,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":","}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093907,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" e"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093907,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":".g"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093907,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":".,"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093907,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" \""}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093907,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":"Like"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093907,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" the"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093907,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" classic"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093907,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" '"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093907,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":"Hello"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093907,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" World"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093907,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":"'"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093907,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" program"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093907,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":"?"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093907,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" Here's"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093907,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" the"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093907,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" output"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093907,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":":"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093907,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" Hello"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093907,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" World"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093907,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":"!\"\n\n"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093907,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":"Since"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093907,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" the"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093907,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" user"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093907,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" is"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093907,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" interacting"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093907,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" with"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093907,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" me"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093907,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":","}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093907,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" I"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093907,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" should"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093907,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" keep"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093907,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" it"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093907,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" light"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093907,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" and"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093907,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" not"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093907,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" over"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093907,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":"com"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093907,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":"plicate"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093907,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" it"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093907,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":".\n\n"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093907,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":"The"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093908,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" user"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093908,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" might"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093908,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" have"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093908,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" meant"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093908,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" \""}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093908,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":"Say"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093908,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" '"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093908,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":"Hello"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093908,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" World"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093908,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":"'\","}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093908,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" with"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093908,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" quotes"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093908,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":","}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093908,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" but"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093908,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" they"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093908,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" didn't"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093908,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" use"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093908,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" them"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093908,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":"."}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093908,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" Still"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093908,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":","}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093908,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" it's"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093908,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" clear"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093908,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":".\n\n"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093908,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":"Finally"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093908,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":","}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093908,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" remember"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093908,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" my"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093908,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" instructions"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093908,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":":"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093908,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" \""}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093908,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":"You"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093908,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" are"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093908,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" a"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093908,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" helpful"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093908,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" assistant"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093908,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":".\""}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093908,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" So"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093908,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":","}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093908,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" prioritize"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093908,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" being"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093908,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" helpful"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093908,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":".\n\n"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093908,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":"Response"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093908,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" plan"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093908,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":":\n\n"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093908,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":"-"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093908,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" A"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093908,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":"cknowledge"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093908,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" the"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093908,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" request"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093908,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":".\n\n"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093908,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":"-"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093908,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" Provide"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093908,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" the"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093908,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" output"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093908,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":".\n\n"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093908,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":"-"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093908,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" Offer"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093908,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" more"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093908,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" help"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093908,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" if"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093908,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":" needed"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093908,"model":"grok-3-mini","choices":[{"index":0,"delta":{"reasoning_content":"."}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093908,"model":"grok-3-mini","choices":[{"index":0,"delta":{"content":"Hello"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093908,"model":"grok-3-mini","choices":[{"index":0,"delta":{"content":" World"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093908,"model":"grok-3-mini","choices":[{"index":0,"delta":{"content":"!"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093908,"model":"grok-3-mini","choices":[{"index":0,"delta":{"content":" "}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093908,"model":"grok-3-mini","choices":[{"index":0,"delta":{"content":"😊"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093908,"model":"grok-3-mini","choices":[{"index":0,"delta":{"content":"\n\n"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093908,"model":"grok-3-mini","choices":[{"index":0,"delta":{"content":"If"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093908,"model":"grok-3-mini","choices":[{"index":0,"delta":{"content":" you"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093908,"model":"grok-3-mini","choices":[{"index":0,"delta":{"content":" meant"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093908,"model":"grok-3-mini","choices":[{"index":0,"delta":{"content":" something"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093908,"model":"grok-3-mini","choices":[{"index":0,"delta":{"content":" else"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093908,"model":"grok-3-mini","choices":[{"index":0,"delta":{"content":","}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093908,"model":"grok-3-mini","choices":[{"index":0,"delta":{"content":" like"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093908,"model":"grok-3-mini","choices":[{"index":0,"delta":{"content":" a"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093908,"model":"grok-3-mini","choices":[{"index":0,"delta":{"content":" programming"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093908,"model":"grok-3-mini","choices":[{"index":0,"delta":{"content":" example"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093908,"model":"grok-3-mini","choices":[{"index":0,"delta":{"content":" or"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093908,"model":"grok-3-mini","choices":[{"index":0,"delta":{"content":" more"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093908,"model":"grok-3-mini","choices":[{"index":0,"delta":{"content":" details"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093908,"model":"grok-3-mini","choices":[{"index":0,"delta":{"content":","}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093908,"model":"grok-3-mini","choices":[{"index":0,"delta":{"content":" just"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093908,"model":"grok-3-mini","choices":[{"index":0,"delta":{"content":" let"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093908,"model":"grok-3-mini","choices":[{"index":0,"delta":{"content":" me"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093908,"model":"grok-3-mini","choices":[{"index":0,"delta":{"content":" know"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093908,"model":"grok-3-mini","choices":[{"index":0,"delta":{"content":"—"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093908,"model":"grok-3-mini","choices":[{"index":0,"delta":{"content":"I'm"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093908,"model":"grok-3-mini","choices":[{"index":0,"delta":{"content":" here"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093908,"model":"grok-3-mini","choices":[{"index":0,"delta":{"content":" to"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093908,"model":"grok-3-mini","choices":[{"index":0,"delta":{"content":" help"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093908,"model":"grok-3-mini","choices":[{"index":0,"delta":{"content":"!"}}],"system_fingerprint":"fp_2a885414fb"} + +data: {"id":"adfb55b2-61c6-c2d6-e210-48cff30943e5","object":"chat.completion.chunk","created":1769093908,"model":"grok-3-mini","choices":[{"index":0,"delta":{},"finish_reason":"stop"}],"system_fingerprint":"fp_2a885414fb"} + +data: [DONE] diff --git a/tests/contract/testdata/xai/chat_with_params.json b/tests/contract/testdata/xai/chat_with_params.json new file mode 100644 index 00000000..ba7dacab --- /dev/null +++ b/tests/contract/testdata/xai/chat_with_params.json @@ -0,0 +1,38 @@ +{ + "id": "f3401ad9-5c67-4aed-e412-b0b130412352", + "object": "chat.completion", + "created": 1769093909, + "model": "grok-3-mini", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": "Mercury, Venus, Earth.", + "reasoning_content": "First, the user asked: \"Name 3 planets.\" I need to be concise, as per my system prompt.\n\nPlanets in our solar system include: Mercury, Venus, Earth, Mars, Jupiter, Saturn, Uranus, Neptune. Pluto is no longer considered a planet.\n\nI should name three planets. To keep it simple, I'll pick the first three: Mercury, Venus, Earth.\n\nMy response should be straightforward and not verbose. So, just list them out.\n\nPossible response: \"1. Mercury, 2. Venus, 3. Earth.\"\n\nOr simply: \"Mercury, Venus, Earth.\"\n\nThat seems concise enough.\n\nIs there any context or specification? The user didn't specify which planets or from which solar system, so assuming our solar system is fine.\n\nEnsure the response is accurate and helpful.\n\nFinal response: Keep it to just the names.", + "refusal": null + }, + "finish_reason": "stop" + } + ], + "usage": { + "prompt_tokens": 19, + "completion_tokens": 6, + "total_tokens": 198, + "prompt_tokens_details": { + "text_tokens": 19, + "audio_tokens": 0, + "image_tokens": 0, + "cached_tokens": 4 + }, + "completion_tokens_details": { + "reasoning_tokens": 173, + "audio_tokens": 0, + "accepted_prediction_tokens": 0, + "rejected_prediction_tokens": 0 + }, + "num_sources_used": 0, + "cost_in_usd_ticks": 943000 + }, + "system_fingerprint": "fp_2a885414fb" +} diff --git a/tests/contract/testdata/xai/models.json b/tests/contract/testdata/xai/models.json new file mode 100644 index 00000000..f207d794 --- /dev/null +++ b/tests/contract/testdata/xai/models.json @@ -0,0 +1,65 @@ +{ + "data": [ + { + "id": "grok-2-vision-1212", + "created": 1733961600, + "object": "model", + "owned_by": "xai" + }, + { + "id": "grok-3", + "created": 1743724800, + "object": "model", + "owned_by": "xai" + }, + { + "id": "grok-3-mini", + "created": 1743724800, + "object": "model", + "owned_by": "xai" + }, + { + "id": "grok-4-0709", + "created": 1752019200, + "object": "model", + "owned_by": "xai" + }, + { + "id": "grok-4-1-fast-non-reasoning", + "created": 1763510400, + "object": "model", + "owned_by": "xai" + }, + { + "id": "grok-4-1-fast-reasoning", + "created": 1763510400, + "object": "model", + "owned_by": "xai" + }, + { + "id": "grok-4-fast-non-reasoning", + "created": 1756944000, + "object": "model", + "owned_by": "xai" + }, + { + "id": "grok-4-fast-reasoning", + "created": 1756944000, + "object": "model", + "owned_by": "xai" + }, + { + "id": "grok-code-fast-1", + "created": 1755993600, + "object": "model", + "owned_by": "xai" + }, + { + "id": "grok-2-image-1212", + "created": 1736726400, + "object": "model", + "owned_by": "xai" + } + ], + "object": "list" +} diff --git a/tests/contract/xai_test.go b/tests/contract/xai_test.go new file mode 100644 index 00000000..866e212f --- /dev/null +++ b/tests/contract/xai_test.go @@ -0,0 +1,112 @@ +//go:build contract + +package contract + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "gomodel/internal/core" +) + +func TestXAI_ChatCompletion(t *testing.T) { + if !goldenFileExists(t, "xai/chat_completion.json") { + t.Skip("golden file not found - run 'make record-api' to generate") + } + + resp := loadGoldenFile[core.ChatResponse](t, "xai/chat_completion.json") + + t.Run("Contract", func(t *testing.T) { + // Validate required fields exist (structure validation) + assert.NotEmpty(t, resp.ID, "response ID should not be empty") + assert.NotEmpty(t, resp.Object, "response object should not be empty") + assert.Equal(t, "chat.completion", resp.Object, "object should be chat.completion") + assert.NotEmpty(t, resp.Model, "model should not be empty") + assert.NotZero(t, resp.Created, "created timestamp should not be zero") + + // Validate choices structure + require.NotEmpty(t, resp.Choices, "choices should not be empty") + choice := resp.Choices[0] + assert.GreaterOrEqual(t, choice.Index, 0, "choice index should be >= 0") + assert.NotNil(t, choice.Message, "choice message should not be nil") + assert.NotEmpty(t, choice.Message.Role, "message role should not be empty") + assert.Equal(t, "assistant", choice.Message.Role, "message role should be assistant") + assert.NotEmpty(t, choice.FinishReason, "finish reason should not be empty") + + // Validate usage structure + assert.GreaterOrEqual(t, resp.Usage.PromptTokens, 0, "prompt tokens should be >= 0") + assert.GreaterOrEqual(t, resp.Usage.CompletionTokens, 0, "completion tokens should be >= 0") + assert.GreaterOrEqual(t, resp.Usage.TotalTokens, 0, "total tokens should be >= 0") + }) + + t.Run("ModelPrefix", func(t *testing.T) { + // xAI models typically contain "grok" + assert.Contains(t, resp.Model, "grok", "xAI model should contain 'grok'") + }) +} + +func TestXAI_ModelsResponse_Contract(t *testing.T) { + if !goldenFileExists(t, "xai/models.json") { + t.Skip("golden file not found - run 'make record-api' to generate") + } + + resp := loadGoldenFile[core.ModelsResponse](t, "xai/models.json") + + // Validate required fields + assert.Equal(t, "list", resp.Object, "object should be 'list'") + assert.NotEmpty(t, resp.Data, "models list should not be empty") + + // Validate each model structure + for i, model := range resp.Data { + assert.NotEmpty(t, model.ID, "model %d: ID should not be empty", i) + assert.Equal(t, "model", model.Object, "model %d: object should be 'model'", i) + assert.NotEmpty(t, model.OwnedBy, "model %d: owned_by should not be empty", i) + } + + // Check for some expected models (Grok variants) + modelIDs := make(map[string]bool) + for _, model := range resp.Data { + modelIDs[model.ID] = true + } + + // At least one Grok model should exist + hasGrok := false + for id := range modelIDs { + if len(id) >= 4 && id[:4] == "grok" { + hasGrok = true + break + } + } + assert.True(t, hasGrok, "expected at least one Grok model in models list") +} + +func TestXAI_StreamingFormat_Contract(t *testing.T) { + if !goldenFileExists(t, "xai/chat_completion_stream.txt") { + t.Skip("golden file not found - run 'make record-api' to generate") + } + + data := loadGoldenFileRaw(t, "xai/chat_completion_stream.txt") + + // Streaming responses should be in SSE format + assert.Contains(t, string(data), "data:", "streaming response should contain SSE data lines") + + // Should end with [DONE] + assert.Contains(t, string(data), "[DONE]", "streaming response should end with [DONE]") +} + +func TestXAI_ChatWithParams(t *testing.T) { + if !goldenFileExists(t, "xai/chat_with_params.json") { + t.Skip("golden file not found - run 'make record-api' to generate") + } + + resp := loadGoldenFile[core.ChatResponse](t, "xai/chat_with_params.json") + + t.Run("Contract", func(t *testing.T) { + assert.NotEmpty(t, resp.ID, "response ID should not be empty") + assert.Equal(t, "chat.completion", resp.Object, "object should be chat.completion") + require.NotEmpty(t, resp.Choices, "choices should not be empty") + assert.Equal(t, "assistant", resp.Choices[0].Message.Role, "message role should be assistant") + }) +} diff --git a/tests/e2e/auditlog_test.go b/tests/e2e/auditlog_test.go index df0f7812..6d892e6a 100644 --- a/tests/e2e/auditlog_test.go +++ b/tests/e2e/auditlog_test.go @@ -26,8 +26,6 @@ import ( type mockLogStore struct { mu sync.Mutex entries []*auditlog.LogEntry - flushed bool - closed bool } func newMockLogStore() *mockLogStore { @@ -44,16 +42,10 @@ func (m *mockLogStore) WriteBatch(_ context.Context, entries []*auditlog.LogEntr } func (m *mockLogStore) Flush(_ context.Context) error { - m.mu.Lock() - defer m.mu.Unlock() - m.flushed = true return nil } func (m *mockLogStore) Close() error { - m.mu.Lock() - defer m.mu.Unlock() - m.closed = true return nil } diff --git a/tests/e2e/auth_test.go b/tests/e2e/auth_test.go index a3a974db..8df0f605 100644 --- a/tests/e2e/auth_test.go +++ b/tests/e2e/auth_test.go @@ -22,92 +22,17 @@ import ( const testMasterKey = "test-secret-key-12345" -// mockProviderForAuth is a simple mock provider for authentication tests -type mockProviderForAuth struct { - models []core.Model -} - -func (m *mockProviderForAuth) ChatCompletion(ctx context.Context, req *core.ChatRequest) (*core.ChatResponse, error) { - return &core.ChatResponse{ - ID: "mock-chat-id", - Object: "chat.completion", - Created: 1234567890, - Model: req.Model, - Choices: []core.Choice{ - { - Index: 0, - Message: core.Message{ - Role: "assistant", - Content: "Mock response", - }, - FinishReason: "stop", - }, - }, - }, nil -} - -func (m *mockProviderForAuth) StreamChatCompletion(ctx context.Context, req *core.ChatRequest) (io.ReadCloser, error) { - // Return a simple SSE stream - data := `data: {"id":"mock-stream","object":"chat.completion.chunk","created":1234567890,"model":"` + req.Model + `","choices":[{"index":0,"delta":{"content":"test"},"finish_reason":null}]} - -data: [DONE] -` - return io.NopCloser(bytes.NewBufferString(data)), nil -} - -func (m *mockProviderForAuth) ListModels(ctx context.Context) (*core.ModelsResponse, error) { - return &core.ModelsResponse{ - Object: "list", - Data: m.models, - }, nil -} - -func (m *mockProviderForAuth) Responses(ctx context.Context, req *core.ResponsesRequest) (*core.ResponsesResponse, error) { - return &core.ResponsesResponse{ - ID: "mock-responses-id", - Object: "response", - CreatedAt: 1234567890, - Model: req.Model, - Status: "completed", - Output: []core.ResponsesOutputItem{ - { - ID: "msg-1", - Type: "message", - Role: "assistant", - Content: []core.ResponsesContentItem{ - { - Type: "text", - Text: "Mock response", - }, - }, - }, - }, - }, nil -} - -func (m *mockProviderForAuth) StreamResponses(ctx context.Context, req *core.ResponsesRequest) (io.ReadCloser, error) { - // Return a simple SSE stream - data := `data: {"type":"content_block_delta","delta":{"type":"text_delta","text":"test"}} - -data: [DONE] -` - return io.NopCloser(bytes.NewBufferString(data)), nil -} - -// setupAuthServer creates a new server instance with authentication enabled +// setupAuthServer creates a new server instance with authentication enabled. +// Uses TestProvider from main_test.go for model support. func setupAuthServer(t *testing.T, masterKey string) *server.Server { t.Helper() - // Create a mock provider - mockProvider := &mockProviderForAuth{ - models: []core.Model{ - {ID: "mock-model-1", Object: "model", OwnedBy: "mock"}, - }, - } + // Create test provider using the shared TestProvider + testProvider := NewTestProvider(mockLLMURL, "sk-test-key-12345") // Create registry and register mock provider registry := providers.NewModelRegistry() - registry.RegisterProvider(mockProvider) + registry.RegisterProvider(testProvider) // Initialize registry synchronously for tests if err := registry.Initialize(context.Background()); err != nil { @@ -225,7 +150,7 @@ func TestAuthenticationE2E(t *testing.T) { method: http.MethodPost, authHeader: "Bearer " + testMasterKey, body: map[string]interface{}{ - "model": "mock-model-1", + "model": "gpt-4", "messages": []map[string]string{ {"role": "user", "content": "Hello"}, }, @@ -244,7 +169,7 @@ func TestAuthenticationE2E(t *testing.T) { method: http.MethodPost, authHeader: "", body: map[string]interface{}{ - "model": "mock-model-1", + "model": "gpt-4", "messages": []map[string]string{ {"role": "user", "content": "Hello"}, }, @@ -263,7 +188,7 @@ func TestAuthenticationE2E(t *testing.T) { method: http.MethodPost, authHeader: "Bearer " + testMasterKey, body: map[string]interface{}{ - "model": "mock-model-1", + "model": "gpt-4", "input": "Hello", }, expectedStatus: http.StatusOK, @@ -279,7 +204,7 @@ func TestAuthenticationE2E(t *testing.T) { method: http.MethodPost, authHeader: "", body: map[string]interface{}{ - "model": "mock-model-1", + "model": "gpt-4", "input": "Hello", }, expectedStatus: http.StatusUnauthorized, @@ -314,7 +239,7 @@ func TestAuthenticationE2E(t *testing.T) { resp, err := http.DefaultClient.Do(req) require.NoError(t, err) - defer resp.Body.Close() + defer closeBody(resp) assert.Equal(t, tt.expectedStatus, resp.StatusCode) @@ -375,7 +300,7 @@ func TestAuthenticationDisabled(t *testing.T) { resp, err := http.DefaultClient.Do(req) require.NoError(t, err) - defer resp.Body.Close() + defer closeBody(resp) assert.Equal(t, tt.expectedStatus, resp.StatusCode) }) @@ -389,7 +314,7 @@ func TestAuthenticationStreamingEndpoints(t *testing.T) { t.Run("streaming chat completion with valid auth", func(t *testing.T) { reqBody := map[string]interface{}{ - "model": "mock-model-1", + "model": "gpt-4", "stream": true, "messages": []map[string]string{ {"role": "user", "content": "Hello"}, @@ -406,7 +331,7 @@ func TestAuthenticationStreamingEndpoints(t *testing.T) { resp, err := http.DefaultClient.Do(req) require.NoError(t, err) - defer resp.Body.Close() + defer closeBody(resp) assert.Equal(t, http.StatusOK, resp.StatusCode) assert.Equal(t, "text/event-stream", resp.Header.Get("Content-Type")) @@ -414,7 +339,7 @@ func TestAuthenticationStreamingEndpoints(t *testing.T) { t.Run("streaming chat completion without auth", func(t *testing.T) { reqBody := map[string]interface{}{ - "model": "mock-model-1", + "model": "gpt-4", "stream": true, "messages": []map[string]string{ {"role": "user", "content": "Hello"}, @@ -430,7 +355,7 @@ func TestAuthenticationStreamingEndpoints(t *testing.T) { resp, err := http.DefaultClient.Do(req) require.NoError(t, err) - defer resp.Body.Close() + defer closeBody(resp) assert.Equal(t, http.StatusUnauthorized, resp.StatusCode) @@ -444,7 +369,7 @@ func TestAuthenticationStreamingEndpoints(t *testing.T) { t.Run("streaming responses with valid auth", func(t *testing.T) { reqBody := map[string]interface{}{ - "model": "mock-model-1", + "model": "gpt-4", "stream": true, "input": "Hello", } @@ -459,7 +384,7 @@ func TestAuthenticationStreamingEndpoints(t *testing.T) { resp, err := http.DefaultClient.Do(req) require.NoError(t, err) - defer resp.Body.Close() + defer closeBody(resp) assert.Equal(t, http.StatusOK, resp.StatusCode) assert.Equal(t, "text/event-stream", resp.Header.Get("Content-Type")) @@ -467,7 +392,7 @@ func TestAuthenticationStreamingEndpoints(t *testing.T) { t.Run("streaming responses without auth", func(t *testing.T) { reqBody := map[string]interface{}{ - "model": "mock-model-1", + "model": "gpt-4", "stream": true, "input": "Hello", } @@ -481,7 +406,7 @@ func TestAuthenticationStreamingEndpoints(t *testing.T) { resp, err := http.DefaultClient.Do(req) require.NoError(t, err) - defer resp.Body.Close() + defer closeBody(resp) assert.Equal(t, http.StatusUnauthorized, resp.StatusCode) @@ -536,7 +461,7 @@ func TestAuthenticationCaseSensitivity(t *testing.T) { resp, err := http.DefaultClient.Do(req) require.NoError(t, err) - defer resp.Body.Close() + defer closeBody(resp) assert.Equal(t, tt.expectedStatus, resp.StatusCode) }) @@ -574,7 +499,7 @@ func TestAuthenticationWithSpecialCharacters(t *testing.T) { resp, err := http.DefaultClient.Do(req) require.NoError(t, err) - defer resp.Body.Close() + defer closeBody(resp) assert.Equal(t, http.StatusOK, resp.StatusCode) @@ -585,7 +510,7 @@ func TestAuthenticationWithSpecialCharacters(t *testing.T) { resp, err = http.DefaultClient.Do(req) require.NoError(t, err) - defer resp.Body.Close() + defer closeBody(resp) assert.Equal(t, http.StatusUnauthorized, resp.StatusCode) }) @@ -649,7 +574,7 @@ func TestAuthenticationBearerPrefixVariations(t *testing.T) { resp, err := http.DefaultClient.Do(req) require.NoError(t, err) - defer resp.Body.Close() + defer closeBody(resp) assert.Equal(t, tt.expectedStatus, resp.StatusCode) }) diff --git a/tests/e2e/mock_provider.go b/tests/e2e/mock_provider.go index 74f68bc1..cf2d9990 100644 --- a/tests/e2e/mock_provider.go +++ b/tests/e2e/mock_provider.go @@ -414,60 +414,6 @@ func (m *MockLLMServer) Close() { m.server.Close() } -// SetResponseDelay sets a delay before responding. -func (m *MockLLMServer) SetResponseDelay(d time.Duration) { - m.mu.Lock() - defer m.mu.Unlock() - m.responseDelay = d -} - -// SetCustomHandler sets a custom handler for specific test cases. -func (m *MockLLMServer) SetCustomHandler(handler func(w http.ResponseWriter, r *http.Request) bool) { - m.mu.Lock() - defer m.mu.Unlock() - m.customHandler = handler -} - -// ClearCustomHandler removes the custom handler. -func (m *MockLLMServer) ClearCustomHandler() { - m.mu.Lock() - defer m.mu.Unlock() - m.customHandler = nil -} - -// FailNextRequest causes the next request to fail with the given status code. -func (m *MockLLMServer) FailNextRequest(statusCode int, message string) { - m.mu.Lock() - defer m.mu.Unlock() - m.failNext = true - m.failWithCode = statusCode - m.failMessage = message -} - -// GetRequests returns all recorded requests. -func (m *MockLLMServer) GetRequests() []RecordedRequest { - m.mu.Lock() - defer m.mu.Unlock() - return append([]RecordedRequest{}, m.requests...) -} - -// ClearRequests clears all recorded requests. -func (m *MockLLMServer) ClearRequests() { - m.mu.Lock() - defer m.mu.Unlock() - m.requests = make([]RecordedRequest, 0) -} - -// LastRequest returns the most recent recorded request. -func (m *MockLLMServer) LastRequest() *RecordedRequest { - m.mu.Lock() - defer m.mu.Unlock() - if len(m.requests) == 0 { - return nil - } - return &m.requests[len(m.requests)-1] -} - // forwardChatRequest forwards a chat request to the mock server. func forwardChatRequest(ctx context.Context, client *http.Client, baseURL, apiKey string, req *core.ChatRequest, stream bool) (*core.ChatResponse, error) { req.Stream = stream diff --git a/tests/integration/.cache/models.json b/tests/integration/.cache/models.json new file mode 100644 index 00000000..e683c8fb --- /dev/null +++ b/tests/integration/.cache/models.json @@ -0,0 +1,24 @@ +{ + "version": 1, + "updated_at": "2026-01-22T14:33:27.648994Z", + "models": { + "gpt-3.5-turbo": { + "provider_type": "test", + "object": "model", + "owned_by": "openai", + "created": 0 + }, + "gpt-4": { + "provider_type": "test", + "object": "model", + "owned_by": "openai", + "created": 0 + }, + "gpt-4.1": { + "provider_type": "test", + "object": "model", + "owned_by": "openai", + "created": 0 + } + } +} diff --git a/tests/integration/auditlog_test.go b/tests/integration/auditlog_test.go new file mode 100644 index 00000000..44d6dddc --- /dev/null +++ b/tests/integration/auditlog_test.go @@ -0,0 +1,275 @@ +//go:build integration + +package integration + +import ( + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "gomodel/tests/integration/dbassert" +) + +func TestAuditLog_CapturesAllFields_PostgreSQL(t *testing.T) { + fixture := SetupTestServer(t, TestServerConfig{ + DBType: "postgresql", + AuditLogEnabled: true, + UsageEnabled: false, + LogBodies: true, + LogHeaders: true, + OnlyModelInteractions: false, + }) + + // Generate unique request ID to isolate this test's data + requestID := uuid.New().String() + + // Make HTTP request with custom request ID header + payload := newChatRequest("gpt-4", "Hello, world!") + resp := sendChatRequestWithHeaders(t, fixture.ServerURL, payload, map[string]string{ + "X-Request-ID": requestID, + }) + require.Equal(t, 200, resp.StatusCode) + closeBody(resp) + + // CRITICAL: Flush before querying DB + fixture.FlushAndClose(t) + + // Query and assert DB state + entries := dbassert.QueryAuditLogsByRequestID(t, fixture.PgPool, requestID) + require.Len(t, entries, 1, "expected exactly one audit log entry") + + entry := entries[0] + + // Assert field completeness + dbassert.AssertAuditLogFieldCompleteness(t, entry) + + // Assert specific values + dbassert.AssertAuditLogMatches(t, dbassert.ExpectedAuditLog{ + Model: "gpt-4", + StatusCode: 200, + Method: "POST", + Path: "/v1/chat/completions", + RequestID: requestID, + }, entry) + + // Assert duration is positive + dbassert.AssertAuditLogDurationPositive(t, entry) + + // Assert no error + dbassert.AssertNoErrorType(t, entry) + + // Assert bodies and headers are logged + dbassert.AssertAuditLogHasData(t, entry) + dbassert.AssertAuditLogHasBody(t, entry, true, true) + dbassert.AssertAuditLogHasHeaders(t, entry, true, true) +} + +func TestAuditLog_CapturesAllFields_MongoDB(t *testing.T) { + fixture := SetupTestServer(t, TestServerConfig{ + DBType: "mongodb", + AuditLogEnabled: true, + UsageEnabled: false, + LogBodies: true, + LogHeaders: true, + OnlyModelInteractions: false, + }) + + // Generate unique request ID + requestID := uuid.New().String() + + // Make HTTP request + payload := newChatRequest("gpt-4", "Hello, world!") + resp := sendChatRequestWithHeaders(t, fixture.ServerURL, payload, map[string]string{ + "X-Request-ID": requestID, + }) + require.Equal(t, 200, resp.StatusCode) + closeBody(resp) + + // CRITICAL: Flush before querying DB + fixture.FlushAndClose(t) + + // Query and assert DB state + entries := dbassert.QueryAuditLogsByRequestIDMongo(t, fixture.MongoDb, requestID) + require.Len(t, entries, 1, "expected exactly one audit log entry") + + entry := entries[0] + + // Assert field completeness + dbassert.AssertAuditLogFieldCompleteness(t, entry) + + // Assert specific values + dbassert.AssertAuditLogMatches(t, dbassert.ExpectedAuditLog{ + Model: "gpt-4", + StatusCode: 200, + Method: "POST", + Path: "/v1/chat/completions", + RequestID: requestID, + }, entry) +} + +func TestAuditLog_OnlyModelInteractions_PostgreSQL(t *testing.T) { + fixture := SetupTestServer(t, TestServerConfig{ + DBType: "postgresql", + AuditLogEnabled: true, + UsageEnabled: false, + OnlyModelInteractions: true, + }) + + // Clear any existing entries first + dbassert.ClearAuditLogs(t, fixture.PgPool) + + requestID := uuid.New().String() + + // Make a model request (should be logged) + payload := newChatRequest("gpt-4", "Hello!") + resp := sendChatRequestWithHeaders(t, fixture.ServerURL, payload, map[string]string{ + "X-Request-ID": requestID, + }) + require.Equal(t, 200, resp.StatusCode) + closeBody(resp) + + // Flush and query + fixture.FlushAndClose(t) + + // Should have entry for chat completion + entries := dbassert.QueryAuditLogsByRequestID(t, fixture.PgPool, requestID) + require.Len(t, entries, 1, "expected one audit log entry for model interaction") + + // Health endpoint should NOT be logged when OnlyModelInteractions is true + // (we can't easily test this without additional infrastructure, but + // the config is correctly set) +} + +func TestAuditLog_WithoutBodies_PostgreSQL(t *testing.T) { + fixture := SetupTestServer(t, TestServerConfig{ + DBType: "postgresql", + AuditLogEnabled: true, + UsageEnabled: false, + LogBodies: false, + LogHeaders: false, + OnlyModelInteractions: false, + }) + + requestID := uuid.New().String() + + payload := newChatRequest("gpt-4", "Hello!") + resp := sendChatRequestWithHeaders(t, fixture.ServerURL, payload, map[string]string{ + "X-Request-ID": requestID, + }) + require.Equal(t, 200, resp.StatusCode) + closeBody(resp) + + fixture.FlushAndClose(t) + + entries := dbassert.QueryAuditLogsByRequestID(t, fixture.PgPool, requestID) + require.Len(t, entries, 1) + + entry := entries[0] + + // Bodies should not be logged + if entry.Data != nil { + assert.Nil(t, entry.Data.RequestBody, "request body should not be logged") + assert.Nil(t, entry.Data.ResponseBody, "response body should not be logged") + assert.Nil(t, entry.Data.RequestHeaders, "request headers should not be logged") + assert.Nil(t, entry.Data.ResponseHeaders, "response headers should not be logged") + } +} + +func TestAuditLog_ResponsesEndpoint_PostgreSQL(t *testing.T) { + fixture := SetupTestServer(t, TestServerConfig{ + DBType: "postgresql", + AuditLogEnabled: true, + UsageEnabled: false, + LogBodies: true, + OnlyModelInteractions: false, + }) + + requestID := uuid.New().String() + + payload := newResponsesRequest("gpt-4", "Hello!") + resp := sendResponsesRequestWithHeaders(t, fixture.ServerURL, payload, map[string]string{ + "X-Request-ID": requestID, + }) + require.Equal(t, 200, resp.StatusCode) + closeBody(resp) + + fixture.FlushAndClose(t) + + entries := dbassert.QueryAuditLogsByRequestID(t, fixture.PgPool, requestID) + require.Len(t, entries, 1) + + dbassert.AssertAuditLogMatches(t, dbassert.ExpectedAuditLog{ + Model: "gpt-4", + StatusCode: 200, + Method: "POST", + Path: "/v1/responses", + RequestID: requestID, + }, entries[0]) +} + +func TestAuditLog_MultipleRequests_PostgreSQL(t *testing.T) { + fixture := SetupTestServer(t, TestServerConfig{ + DBType: "postgresql", + AuditLogEnabled: true, + UsageEnabled: false, + OnlyModelInteractions: false, + }) + + // Make multiple requests with unique IDs + requestIDs := make([]string, 3) + for i := 0; i < 3; i++ { + requestIDs[i] = uuid.New().String() + payload := newChatRequest("gpt-4", "Hello!") + resp := sendChatRequestWithHeaders(t, fixture.ServerURL, payload, map[string]string{ + "X-Request-ID": requestIDs[i], + }) + require.Equal(t, 200, resp.StatusCode) + closeBody(resp) + } + + fixture.FlushAndClose(t) + + // Verify each request has its own entry + for _, reqID := range requestIDs { + entries := dbassert.QueryAuditLogsByRequestID(t, fixture.PgPool, reqID) + require.Len(t, entries, 1, "expected one entry for request ID %s", reqID) + } +} + +func TestAuditLog_DifferentModels_PostgreSQL(t *testing.T) { + fixture := SetupTestServer(t, TestServerConfig{ + DBType: "postgresql", + AuditLogEnabled: true, + UsageEnabled: false, + OnlyModelInteractions: false, + }) + + // Clear to start fresh + dbassert.ClearAuditLogs(t, fixture.PgPool) + + models := []string{"gpt-4", "gpt-4.1", "gpt-3.5-turbo"} + requestIDs := make(map[string]string) + + for _, model := range models { + reqID := uuid.New().String() + requestIDs[model] = reqID + + payload := newChatRequest(model, "Hello!") + resp := sendChatRequestWithHeaders(t, fixture.ServerURL, payload, map[string]string{ + "X-Request-ID": reqID, + }) + require.Equal(t, 200, resp.StatusCode) + closeBody(resp) + } + + fixture.FlushAndClose(t) + + // Verify each model's entry + for model, reqID := range requestIDs { + entries := dbassert.QueryAuditLogsByRequestID(t, fixture.PgPool, reqID) + require.Len(t, entries, 1, "expected one entry for model %s", model) + assert.Equal(t, model, entries[0].Model, "model mismatch for request %s", reqID) + } +} diff --git a/tests/integration/dbassert/auditlog.go b/tests/integration/dbassert/auditlog.go new file mode 100644 index 00000000..e9c3da10 --- /dev/null +++ b/tests/integration/dbassert/auditlog.go @@ -0,0 +1,223 @@ +//go:build integration + +// Package dbassert provides database assertion helpers for integration tests. +// It supports querying and validating audit logs and usage entries in PostgreSQL and MongoDB. +package dbassert + +import ( + "context" + "testing" + "time" + + "github.com/jackc/pgx/v5/pgxpool" + "github.com/stretchr/testify/require" + "go.mongodb.org/mongo-driver/v2/bson" + "go.mongodb.org/mongo-driver/v2/mongo" + + "gomodel/internal/auditlog" +) + +// AuditLogEntry mirrors auditlog.LogEntry for test assertions. +// We use a separate type to avoid coupling tests to internal implementation details. +type AuditLogEntry struct { + ID string + Timestamp time.Time + DurationNs int64 + Model string + Provider string + StatusCode int + RequestID string + ClientIP string + Method string + Path string + Stream bool + ErrorType string + Data *auditlog.LogData +} + +// QueryAuditLogsByRequestID queries audit logs by request ID from PostgreSQL. +func QueryAuditLogsByRequestID(t *testing.T, pool *pgxpool.Pool, requestID string) []AuditLogEntry { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + query := ` + SELECT id, timestamp, duration_ns, model, provider, status_code, + request_id, client_ip, method, path, stream, error_type, data + FROM audit_logs + WHERE request_id = $1 + ORDER BY timestamp ASC + ` + + rows, err := pool.Query(ctx, query, requestID) + require.NoError(t, err, "failed to query audit logs") + defer rows.Close() + + var entries []AuditLogEntry + for rows.Next() { + var entry AuditLogEntry + var dataJSON []byte + err := rows.Scan( + &entry.ID, &entry.Timestamp, &entry.DurationNs, + &entry.Model, &entry.Provider, &entry.StatusCode, + &entry.RequestID, &entry.ClientIP, &entry.Method, + &entry.Path, &entry.Stream, &entry.ErrorType, &dataJSON, + ) + require.NoError(t, err, "failed to scan audit log row") + + if dataJSON != nil { + entry.Data = unmarshalLogData(t, dataJSON) + } + entries = append(entries, entry) + } + require.NoError(t, rows.Err(), "error iterating audit log rows") + + return entries +} + +// QueryAuditLogsByRequestIDMongo queries audit logs by request ID from MongoDB. +func QueryAuditLogsByRequestIDMongo(t *testing.T, db *mongo.Database, requestID string) []AuditLogEntry { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + collection := db.Collection("audit_logs") + filter := bson.M{"request_id": requestID} + + cursor, err := collection.Find(ctx, filter) + require.NoError(t, err, "failed to query audit logs from MongoDB") + defer cursor.Close(ctx) + + var entries []AuditLogEntry + for cursor.Next(ctx) { + var doc bson.M + err := cursor.Decode(&doc) + require.NoError(t, err, "failed to decode audit log document") + + entry := bsonToAuditLogEntry(t, doc) + entries = append(entries, entry) + } + require.NoError(t, cursor.Err(), "error iterating audit log cursor") + + return entries +} + +// ClearAuditLogs deletes all audit log entries from PostgreSQL. +func ClearAuditLogs(t *testing.T, pool *pgxpool.Pool) { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + _, err := pool.Exec(ctx, "DELETE FROM audit_logs") + require.NoError(t, err, "failed to clear audit logs") +} + +// bsonToAuditLogEntry converts a BSON document to an AuditLogEntry. +func bsonToAuditLogEntry(t *testing.T, doc bson.M) AuditLogEntry { + t.Helper() + entry := AuditLogEntry{} + + if v, ok := doc["_id"].(string); ok { + entry.ID = v + } + if v, ok := doc["timestamp"].(time.Time); ok { + entry.Timestamp = v + } + if v, ok := doc["duration_ns"].(int64); ok { + entry.DurationNs = v + } else if v, ok := doc["duration_ns"].(int32); ok { + entry.DurationNs = int64(v) + } + if v, ok := doc["model"].(string); ok { + entry.Model = v + } + if v, ok := doc["provider"].(string); ok { + entry.Provider = v + } + if v, ok := doc["status_code"].(int32); ok { + entry.StatusCode = int(v) + } else if v, ok := doc["status_code"].(int64); ok { + entry.StatusCode = int(v) + } + if v, ok := doc["request_id"].(string); ok { + entry.RequestID = v + } + if v, ok := doc["client_ip"].(string); ok { + entry.ClientIP = v + } + if v, ok := doc["method"].(string); ok { + entry.Method = v + } + if v, ok := doc["path"].(string); ok { + entry.Path = v + } + if v, ok := doc["stream"].(bool); ok { + entry.Stream = v + } + if v, ok := doc["error_type"].(string); ok { + entry.ErrorType = v + } + + // Handle data field + if dataDoc, ok := doc["data"].(bson.M); ok { + entry.Data = bsonToLogData(dataDoc) + } + + return entry +} + +// bsonToLogData converts a BSON document to auditlog.LogData. +func bsonToLogData(doc bson.M) *auditlog.LogData { + data := &auditlog.LogData{} + + if v, ok := doc["user_agent"].(string); ok { + data.UserAgent = v + } + if v, ok := doc["api_key_hash"].(string); ok { + data.APIKeyHash = v + } + if v, ok := doc["temperature"].(float64); ok { + data.Temperature = &v + } + if v, ok := doc["max_tokens"].(int32); ok { + val := int(v) + data.MaxTokens = &val + } else if v, ok := doc["max_tokens"].(int64); ok { + val := int(v) + data.MaxTokens = &val + } + if v, ok := doc["error_message"].(string); ok { + data.ErrorMessage = v + } + if v, ok := doc["request_headers"].(bson.M); ok { + data.RequestHeaders = bsonMapToStringMap(v) + } + if v, ok := doc["response_headers"].(bson.M); ok { + data.ResponseHeaders = bsonMapToStringMap(v) + } + if v := doc["request_body"]; v != nil { + data.RequestBody = v + } + if v := doc["response_body"]; v != nil { + data.ResponseBody = v + } + if v, ok := doc["request_body_too_big_to_handle"].(bool); ok { + data.RequestBodyTooBigToHandle = v + } + if v, ok := doc["response_body_too_big_to_handle"].(bool); ok { + data.ResponseBodyTooBigToHandle = v + } + + return data +} + +// bsonMapToStringMap converts a bson.M to map[string]string. +func bsonMapToStringMap(m bson.M) map[string]string { + result := make(map[string]string, len(m)) + for k, v := range m { + if s, ok := v.(string); ok { + result[k] = s + } + } + return result +} diff --git a/tests/integration/dbassert/doc.go b/tests/integration/dbassert/doc.go new file mode 100644 index 00000000..28bd2d25 --- /dev/null +++ b/tests/integration/dbassert/doc.go @@ -0,0 +1,4 @@ +// Package dbassert provides database assertion helpers for integration tests. +// +// Run with: go test -tags=integration ./tests/integration/... +package dbassert diff --git a/tests/integration/dbassert/fields.go b/tests/integration/dbassert/fields.go new file mode 100644 index 00000000..312d3f31 --- /dev/null +++ b/tests/integration/dbassert/fields.go @@ -0,0 +1,219 @@ +//go:build integration + +package dbassert + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "gomodel/internal/auditlog" +) + +// RequiredAuditLogFields are fields that must always be populated in an audit log entry. +var RequiredAuditLogFields = []string{ + "ID", + "Timestamp", + "StatusCode", + "Method", + "Path", +} + +// RequiredUsageFields are fields that must always be populated in a usage entry. +var RequiredUsageFields = []string{ + "ID", + "RequestID", + "Timestamp", + "Model", + "Provider", + "Endpoint", +} + +// ExpectedAuditLog contains expected values for audit log assertions. +// Zero values are not checked, allowing partial matching. +type ExpectedAuditLog struct { + Model string + Provider string + StatusCode int + Method string + Path string + Stream bool + ErrorType string + RequestID string +} + +// ExpectedUsage contains expected values for usage assertions. +// Zero values are not checked, allowing partial matching. +type ExpectedUsage struct { + Model string + Provider string + Endpoint string + InputTokens int + OutputTokens int + TotalTokens int + RequestID string +} + +// AssertAuditLogFieldCompleteness verifies that all required fields are populated. +func AssertAuditLogFieldCompleteness(t *testing.T, entry AuditLogEntry) { + t.Helper() + + assert.NotEmpty(t, entry.ID, "audit log ID should not be empty") + assert.False(t, entry.Timestamp.IsZero(), "audit log timestamp should not be zero") + assert.NotZero(t, entry.StatusCode, "audit log status code should not be zero") + assert.NotEmpty(t, entry.Method, "audit log method should not be empty") + assert.NotEmpty(t, entry.Path, "audit log path should not be empty") +} + +// AssertUsageFieldCompleteness verifies that all required fields are populated. +func AssertUsageFieldCompleteness(t *testing.T, entry UsageEntry) { + t.Helper() + + assert.NotEmpty(t, entry.ID, "usage ID should not be empty") + assert.NotEmpty(t, entry.RequestID, "usage request ID should not be empty") + assert.False(t, entry.Timestamp.IsZero(), "usage timestamp should not be zero") + assert.NotEmpty(t, entry.Model, "usage model should not be empty") + assert.NotEmpty(t, entry.Provider, "usage provider should not be empty") + assert.NotEmpty(t, entry.Endpoint, "usage endpoint should not be empty") +} + +// AssertAuditLogMatches verifies that the actual entry matches expected values. +// Only non-zero expected values are checked. +func AssertAuditLogMatches(t *testing.T, expected ExpectedAuditLog, actual AuditLogEntry) { + t.Helper() + + if expected.Model != "" { + assert.Equal(t, expected.Model, actual.Model, "model mismatch") + } + if expected.Provider != "" { + assert.Equal(t, expected.Provider, actual.Provider, "provider mismatch") + } + if expected.StatusCode != 0 { + assert.Equal(t, expected.StatusCode, actual.StatusCode, "status code mismatch") + } + if expected.Method != "" { + assert.Equal(t, expected.Method, actual.Method, "method mismatch") + } + if expected.Path != "" { + assert.Equal(t, expected.Path, actual.Path, "path mismatch") + } + if expected.Stream { + assert.True(t, actual.Stream, "expected stream to be true") + } + if expected.ErrorType != "" { + assert.Equal(t, expected.ErrorType, actual.ErrorType, "error type mismatch") + } + if expected.RequestID != "" { + assert.Equal(t, expected.RequestID, actual.RequestID, "request ID mismatch") + } +} + +// AssertUsageMatches verifies that the actual entry matches expected values. +// Only non-zero expected values are checked. +func AssertUsageMatches(t *testing.T, expected ExpectedUsage, actual UsageEntry) { + t.Helper() + + if expected.Model != "" { + assert.Equal(t, expected.Model, actual.Model, "model mismatch") + } + if expected.Provider != "" { + assert.Equal(t, expected.Provider, actual.Provider, "provider mismatch") + } + if expected.Endpoint != "" { + assert.Equal(t, expected.Endpoint, actual.Endpoint, "endpoint mismatch") + } + if expected.InputTokens != 0 { + assert.Equal(t, expected.InputTokens, actual.InputTokens, "input tokens mismatch") + } + if expected.OutputTokens != 0 { + assert.Equal(t, expected.OutputTokens, actual.OutputTokens, "output tokens mismatch") + } + if expected.TotalTokens != 0 { + assert.Equal(t, expected.TotalTokens, actual.TotalTokens, "total tokens mismatch") + } + if expected.RequestID != "" { + assert.Equal(t, expected.RequestID, actual.RequestID, "request ID mismatch") + } +} + +// AssertAuditLogHasData verifies that the audit log entry has associated data. +func AssertAuditLogHasData(t *testing.T, entry AuditLogEntry) { + t.Helper() + assert.NotNil(t, entry.Data, "audit log should have data field populated") +} + +// AssertAuditLogHasBody verifies that request and/or response bodies are logged. +func AssertAuditLogHasBody(t *testing.T, entry AuditLogEntry, expectRequest, expectResponse bool) { + t.Helper() + require.NotNil(t, entry.Data, "audit log data should not be nil") + + if expectRequest { + assert.NotNil(t, entry.Data.RequestBody, "expected request body to be logged") + } + if expectResponse { + assert.NotNil(t, entry.Data.ResponseBody, "expected response body to be logged") + } +} + +// AssertAuditLogHasHeaders verifies that headers are logged. +func AssertAuditLogHasHeaders(t *testing.T, entry AuditLogEntry, expectRequest, expectResponse bool) { + t.Helper() + require.NotNil(t, entry.Data, "audit log data should not be nil") + + if expectRequest { + assert.NotNil(t, entry.Data.RequestHeaders, "expected request headers to be logged") + } + if expectResponse { + assert.NotNil(t, entry.Data.ResponseHeaders, "expected response headers to be logged") + } +} + +// AssertUsageHasTokens verifies that token counts are populated (non-zero). +func AssertUsageHasTokens(t *testing.T, entry UsageEntry) { + t.Helper() + + // At minimum, total tokens should be non-zero for a valid usage entry + assert.Greater(t, entry.TotalTokens, 0, "total tokens should be greater than zero") +} + +// AssertUsageTokensConsistent verifies that input + output = total tokens. +func AssertUsageTokensConsistent(t *testing.T, entry UsageEntry) { + t.Helper() + + expectedTotal := entry.InputTokens + entry.OutputTokens + assert.Equal(t, expectedTotal, entry.TotalTokens, + "total tokens (%d) should equal input (%d) + output (%d)", + entry.TotalTokens, entry.InputTokens, entry.OutputTokens) +} + +// AssertNoErrorType verifies that the entry has no error type set. +func AssertNoErrorType(t *testing.T, entry AuditLogEntry) { + t.Helper() + assert.Empty(t, entry.ErrorType, "expected no error type, got: %s", entry.ErrorType) +} + +// AssertAuditLogDurationPositive verifies that the duration is positive. +func AssertAuditLogDurationPositive(t *testing.T, entry AuditLogEntry) { + t.Helper() + assert.Greater(t, entry.DurationNs, int64(0), "duration should be positive") +} + +// unmarshalLogData unmarshals JSON bytes to auditlog.LogData. +func unmarshalLogData(t *testing.T, data []byte) *auditlog.LogData { + t.Helper() + var logData auditlog.LogData + err := json.Unmarshal(data, &logData) + require.NoError(t, err, "failed to unmarshal log data") + return &logData +} + +// unmarshalRawData unmarshals JSON bytes to map[string]any. +func unmarshalRawData(t *testing.T, data []byte) map[string]any { + t.Helper() + var rawData map[string]any + err := json.Unmarshal(data, &rawData) + require.NoError(t, err, "failed to unmarshal raw data") + return rawData +} diff --git a/tests/integration/dbassert/usage.go b/tests/integration/dbassert/usage.go new file mode 100644 index 00000000..75378948 --- /dev/null +++ b/tests/integration/dbassert/usage.go @@ -0,0 +1,235 @@ +//go:build integration + +package dbassert + +import ( + "context" + "testing" + "time" + + "github.com/jackc/pgx/v5/pgxpool" + "github.com/stretchr/testify/require" + "go.mongodb.org/mongo-driver/v2/bson" + "go.mongodb.org/mongo-driver/v2/mongo" +) + +// UsageEntry mirrors usage.UsageEntry for test assertions. +type UsageEntry struct { + ID string + RequestID string + ProviderID string + Timestamp time.Time + Model string + Provider string + Endpoint string + InputTokens int + OutputTokens int + TotalTokens int + RawData map[string]any +} + +// QueryUsageByRequestID queries usage entries by request ID from PostgreSQL. +func QueryUsageByRequestID(t *testing.T, pool *pgxpool.Pool, requestID string) []UsageEntry { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + query := ` + SELECT id, request_id, provider_id, timestamp, model, provider, endpoint, + input_tokens, output_tokens, total_tokens, raw_data + FROM usage + WHERE request_id = $1 + ORDER BY timestamp ASC + ` + + rows, err := pool.Query(ctx, query, requestID) + require.NoError(t, err, "failed to query usage entries") + defer rows.Close() + + var entries []UsageEntry + for rows.Next() { + var entry UsageEntry + var rawDataJSON []byte + err := rows.Scan( + &entry.ID, &entry.RequestID, &entry.ProviderID, + &entry.Timestamp, &entry.Model, &entry.Provider, &entry.Endpoint, + &entry.InputTokens, &entry.OutputTokens, &entry.TotalTokens, &rawDataJSON, + ) + require.NoError(t, err, "failed to scan usage row") + + if rawDataJSON != nil { + entry.RawData = unmarshalRawData(t, rawDataJSON) + } + entries = append(entries, entry) + } + require.NoError(t, rows.Err(), "error iterating usage rows") + + return entries +} + +// QueryUsageByRequestIDMongo queries usage entries by request ID from MongoDB. +func QueryUsageByRequestIDMongo(t *testing.T, db *mongo.Database, requestID string) []UsageEntry { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + collection := db.Collection("usage") + filter := bson.M{"request_id": requestID} + + cursor, err := collection.Find(ctx, filter) + require.NoError(t, err, "failed to query usage entries from MongoDB") + defer cursor.Close(ctx) + + var entries []UsageEntry + for cursor.Next(ctx) { + var doc bson.M + err := cursor.Decode(&doc) + require.NoError(t, err, "failed to decode usage document") + + entry := bsonToUsageEntry(t, doc) + entries = append(entries, entry) + } + require.NoError(t, cursor.Err(), "error iterating usage cursor") + + return entries +} + +// CountUsage returns the total count of usage entries in PostgreSQL. +func CountUsage(t *testing.T, pool *pgxpool.Pool) int { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + var count int + err := pool.QueryRow(ctx, "SELECT COUNT(*) FROM usage").Scan(&count) + require.NoError(t, err, "failed to count usage entries") + + return count +} + +// ClearUsage deletes all usage entries from PostgreSQL. +func ClearUsage(t *testing.T, pool *pgxpool.Pool) { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + _, err := pool.Exec(ctx, "DELETE FROM usage") + require.NoError(t, err, "failed to clear usage entries") +} + +// SumTokensByModel returns total token usage grouped by model from PostgreSQL. +func SumTokensByModel(t *testing.T, pool *pgxpool.Pool) map[string]TokenSummary { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + query := ` + SELECT model, SUM(input_tokens), SUM(output_tokens), SUM(total_tokens), COUNT(*) + FROM usage + GROUP BY model + ` + + rows, err := pool.Query(ctx, query) + require.NoError(t, err, "failed to query token summary") + defer rows.Close() + + results := make(map[string]TokenSummary) + for rows.Next() { + var model string + var summary TokenSummary + err := rows.Scan(&model, &summary.InputTokens, &summary.OutputTokens, &summary.TotalTokens, &summary.RequestCount) + require.NoError(t, err, "failed to scan token summary row") + results[model] = summary + } + require.NoError(t, rows.Err(), "error iterating token summary rows") + + return results +} + +// TokenSummary holds aggregated token usage statistics. +type TokenSummary struct { + InputTokens int64 + OutputTokens int64 + TotalTokens int64 + RequestCount int64 +} + +// bsonToUsageEntry converts a BSON document to a UsageEntry. +func bsonToUsageEntry(t *testing.T, doc bson.M) UsageEntry { + t.Helper() + entry := UsageEntry{} + + if v, ok := doc["_id"].(string); ok { + entry.ID = v + } + if v, ok := doc["request_id"].(string); ok { + entry.RequestID = v + } + if v, ok := doc["provider_id"].(string); ok { + entry.ProviderID = v + } + if v, ok := doc["timestamp"].(time.Time); ok { + entry.Timestamp = v + } + if v, ok := doc["model"].(string); ok { + entry.Model = v + } + if v, ok := doc["provider"].(string); ok { + entry.Provider = v + } + if v, ok := doc["endpoint"].(string); ok { + entry.Endpoint = v + } + if v, ok := doc["input_tokens"].(int32); ok { + entry.InputTokens = int(v) + } else if v, ok := doc["input_tokens"].(int64); ok { + entry.InputTokens = int(v) + } + if v, ok := doc["output_tokens"].(int32); ok { + entry.OutputTokens = int(v) + } else if v, ok := doc["output_tokens"].(int64); ok { + entry.OutputTokens = int(v) + } + if v, ok := doc["total_tokens"].(int32); ok { + entry.TotalTokens = int(v) + } else if v, ok := doc["total_tokens"].(int64); ok { + entry.TotalTokens = int(v) + } + if v, ok := doc["raw_data"].(bson.M); ok { + entry.RawData = bsonToMap(v) + } + + return entry +} + +// bsonToMap converts a bson.M to a map[string]any recursively. +func bsonToMap(m bson.M) map[string]any { + result := make(map[string]any, len(m)) + for k, v := range m { + switch val := v.(type) { + case bson.M: + result[k] = bsonToMap(val) + case bson.A: + result[k] = bsonArrayToSlice(val) + default: + result[k] = v + } + } + return result +} + +// bsonArrayToSlice converts a bson.A to []any. +func bsonArrayToSlice(a bson.A) []any { + result := make([]any, len(a)) + for i, v := range a { + switch val := v.(type) { + case bson.M: + result[i] = bsonToMap(val) + case bson.A: + result[i] = bsonArrayToSlice(val) + default: + result[i] = v + } + } + return result +} diff --git a/tests/integration/doc.go b/tests/integration/doc.go new file mode 100644 index 00000000..fe01100f --- /dev/null +++ b/tests/integration/doc.go @@ -0,0 +1,5 @@ +// Package integration provides integration tests that verify database state +// after requests. These tests use real databases via testcontainers. +// +// Run with: go test -tags=integration ./tests/integration/... +package integration diff --git a/tests/integration/helpers_test.go b/tests/integration/helpers_test.go new file mode 100644 index 00000000..a4ebd2d6 --- /dev/null +++ b/tests/integration/helpers_test.go @@ -0,0 +1,161 @@ +//go:build integration + +package integration + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "testing" + + "github.com/stretchr/testify/require" + + "gomodel/internal/core" +) + +// API endpoints +const ( + chatCompletionsPath = "/v1/chat/completions" + responsesPath = "/v1/responses" + modelsPath = "/v1/models" + healthPath = "/health" +) + +// sendChatRequest sends a chat completion request and returns the response. +func sendChatRequest(t *testing.T, serverURL string, payload core.ChatRequest) *http.Response { + t.Helper() + return sendJSONRequest(t, serverURL+chatCompletionsPath, payload, nil) +} + +// sendChatRequestWithHeaders sends a chat completion request with custom headers. +func sendChatRequestWithHeaders(t *testing.T, serverURL string, payload core.ChatRequest, headers map[string]string) *http.Response { + t.Helper() + return sendJSONRequest(t, serverURL+chatCompletionsPath, payload, headers) +} + +// sendResponsesRequestWithHeaders sends a responses API request with custom headers. +func sendResponsesRequestWithHeaders(t *testing.T, serverURL string, payload core.ResponsesRequest, headers map[string]string) *http.Response { + t.Helper() + return sendJSONRequest(t, serverURL+responsesPath, payload, headers) +} + +// sendJSONRequest sends a JSON POST request and returns the response. +func sendJSONRequest(t *testing.T, url string, payload interface{}, headers map[string]string) *http.Response { + t.Helper() + + body, err := json.Marshal(payload) + require.NoError(t, err, "failed to marshal request payload") + + req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(body)) + require.NoError(t, err, "failed to create request") + + req.Header.Set("Content-Type", "application/json") + for k, v := range headers { + req.Header.Set(k, v) + } + + client := &http.Client{} + resp, err := client.Do(req) + require.NoError(t, err, "failed to send request") + + return resp +} + +// closeBody is a helper to close response body in defer statements. +func closeBody(resp *http.Response) { + if resp != nil && resp.Body != nil { + _ = resp.Body.Close() + } +} + +// newChatRequest creates a basic chat request for testing. +func newChatRequest(model, content string) core.ChatRequest { + return core.ChatRequest{ + Model: model, + Messages: []core.Message{ + { + Role: "user", + Content: content, + }, + }, + } +} + +// newResponsesRequest creates a basic responses request for testing. +func newResponsesRequest(model, content string) core.ResponsesRequest { + return core.ResponsesRequest{ + Model: model, + Input: content, // Simple string input for basic testing + } +} + +// forwardChatRequest forwards a chat request to the upstream server. +func forwardChatRequest(ctx context.Context, client *http.Client, baseURL, apiKey string, req *core.ChatRequest) (*core.ChatResponse, error) { + body, err := json.Marshal(req) + if err != nil { + return nil, fmt.Errorf("failed to marshal request: %w", err) + } + + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, baseURL+chatCompletionsPath, bytes.NewReader(body)) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("Authorization", "Bearer "+apiKey) + + resp, err := client.Do(httpReq) + if err != nil { + return nil, fmt.Errorf("failed to send request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + respBody, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("unexpected status %d: %s", resp.StatusCode, string(respBody)) + } + + var chatResp core.ChatResponse + if err := json.NewDecoder(resp.Body).Decode(&chatResp); err != nil { + return nil, fmt.Errorf("failed to decode response: %w", err) + } + + return &chatResp, nil +} + +// forwardResponsesRequest forwards a responses request to the upstream server. +func forwardResponsesRequest(ctx context.Context, client *http.Client, baseURL, apiKey string, req *core.ResponsesRequest) (*core.ResponsesResponse, error) { + body, err := json.Marshal(req) + if err != nil { + return nil, fmt.Errorf("failed to marshal request: %w", err) + } + + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, baseURL+responsesPath, bytes.NewReader(body)) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("Authorization", "Bearer "+apiKey) + + resp, err := client.Do(httpReq) + if err != nil { + return nil, fmt.Errorf("failed to send request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + respBody, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("unexpected status %d: %s", resp.StatusCode, string(respBody)) + } + + var responsesResp core.ResponsesResponse + if err := json.NewDecoder(resp.Body).Decode(&responsesResp); err != nil { + return nil, fmt.Errorf("failed to decode response: %w", err) + } + + return &responsesResp, nil +} diff --git a/tests/integration/main_test.go b/tests/integration/main_test.go new file mode 100644 index 00000000..c3b21398 --- /dev/null +++ b/tests/integration/main_test.go @@ -0,0 +1,214 @@ +//go:build integration + +// Package integration provides integration tests that verify database state +// after HTTP requests. Tests run against real PostgreSQL and MongoDB instances +// using testcontainers-go. +package integration + +import ( + "context" + "fmt" + "log" + "os" + "testing" + "time" + + "github.com/jackc/pgx/v5/pgxpool" + "github.com/testcontainers/testcontainers-go" + "github.com/testcontainers/testcontainers-go/modules/mongodb" + "github.com/testcontainers/testcontainers-go/modules/postgres" + "github.com/testcontainers/testcontainers-go/wait" + "go.mongodb.org/mongo-driver/v2/mongo" + "go.mongodb.org/mongo-driver/v2/mongo/options" +) + +var ( + // PostgreSQL resources + pgContainer *postgres.PostgresContainer + pgPool *pgxpool.Pool + pgURL string + + // MongoDB resources + mongoContainer *mongodb.MongoDBContainer + mongoClient *mongo.Client + mongoDatabase *mongo.Database + mongoURL string + + // Test context + testCtx context.Context + cancelFunc context.CancelFunc +) + +// TestMain sets up and tears down the test containers. +func TestMain(m *testing.M) { + testCtx, cancelFunc = context.WithTimeout(context.Background(), 10*time.Minute) + defer cancelFunc() + + // Start containers in parallel + errCh := make(chan error, 2) + + go func() { + errCh <- setupPostgreSQL(testCtx) + }() + + go func() { + errCh <- setupMongoDB(testCtx) + }() + + // Wait for both containers to start + for i := 0; i < 2; i++ { + if err := <-errCh; err != nil { + log.Printf("Container setup failed: %v", err) + cleanup() + os.Exit(1) + } + } + + log.Println("All containers started successfully") + + // Run tests + code := m.Run() + + // Cleanup + cleanup() + os.Exit(code) +} + +// setupPostgreSQL starts a PostgreSQL container and creates the connection pool. +func setupPostgreSQL(ctx context.Context) error { + var err error + + log.Println("Starting PostgreSQL container...") + pgContainer, err = postgres.Run(ctx, + "postgres:16-alpine", + postgres.WithDatabase("gomodel_test"), + postgres.WithUsername("test"), + postgres.WithPassword("test"), + testcontainers.WithWaitStrategy( + wait.ForLog("database system is ready to accept connections"). + WithOccurrence(2). + WithStartupTimeout(60*time.Second), + ), + ) + if err != nil { + return fmt.Errorf("failed to start PostgreSQL container: %w", err) + } + + // Get connection string + pgURL, err = pgContainer.ConnectionString(ctx, "sslmode=disable") + if err != nil { + return fmt.Errorf("failed to get PostgreSQL connection string: %w", err) + } + + log.Printf("PostgreSQL URL: %s", pgURL) + + // Create connection pool + pgPool, err = pgxpool.New(ctx, pgURL) + if err != nil { + return fmt.Errorf("failed to create PostgreSQL pool: %w", err) + } + + // Verify connection + if err := pgPool.Ping(ctx); err != nil { + return fmt.Errorf("failed to ping PostgreSQL: %w", err) + } + + log.Println("PostgreSQL container ready") + return nil +} + +// setupMongoDB starts a MongoDB container and creates the client. +func setupMongoDB(ctx context.Context) error { + var err error + + log.Println("Starting MongoDB container...") + mongoContainer, err = mongodb.Run(ctx, "mongo:7") + if err != nil { + return fmt.Errorf("failed to start MongoDB container: %w", err) + } + + // Get connection string + mongoURL, err = mongoContainer.ConnectionString(ctx) + if err != nil { + return fmt.Errorf("failed to get MongoDB connection string: %w", err) + } + + log.Printf("MongoDB URL: %s", mongoURL) + + // Create client + mongoClient, err = mongo.Connect(options.Client().ApplyURI(mongoURL)) + if err != nil { + return fmt.Errorf("failed to create MongoDB client: %w", err) + } + + // Verify connection + if err := mongoClient.Ping(ctx, nil); err != nil { + return fmt.Errorf("failed to ping MongoDB: %w", err) + } + + // Get database reference + mongoDatabase = mongoClient.Database("gomodel_test") + + log.Println("MongoDB container ready") + return nil +} + +// cleanup terminates all containers and connections. +func cleanup() { + log.Println("Cleaning up test resources...") + + if pgPool != nil { + pgPool.Close() + } + + if pgContainer != nil { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + if err := pgContainer.Terminate(ctx); err != nil { + log.Printf("Failed to terminate PostgreSQL container: %v", err) + } + } + + if mongoClient != nil { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + if err := mongoClient.Disconnect(ctx); err != nil { + log.Printf("Failed to disconnect MongoDB client: %v", err) + } + } + + if mongoContainer != nil { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + if err := mongoContainer.Terminate(ctx); err != nil { + log.Printf("Failed to terminate MongoDB container: %v", err) + } + } + + log.Println("Cleanup complete") +} + +// GetPostgreSQLPool returns the PostgreSQL connection pool for tests. +func GetPostgreSQLPool() *pgxpool.Pool { + return pgPool +} + +// GetPostgreSQLURL returns the PostgreSQL connection URL. +func GetPostgreSQLURL() string { + return pgURL +} + +// GetMongoDatabase returns the MongoDB database for tests. +func GetMongoDatabase() *mongo.Database { + return mongoDatabase +} + +// GetMongoURL returns the MongoDB connection URL. +func GetMongoURL() string { + return mongoURL +} + +// GetTestContext returns the shared test context. +func GetTestContext() context.Context { + return testCtx +} diff --git a/tests/integration/setup_test.go b/tests/integration/setup_test.go new file mode 100644 index 00000000..39a56bbe --- /dev/null +++ b/tests/integration/setup_test.go @@ -0,0 +1,412 @@ +//go:build integration + +package integration + +import ( + "context" + "fmt" + "io" + "net" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/jackc/pgx/v5/pgxpool" + "github.com/stretchr/testify/require" + "go.mongodb.org/mongo-driver/v2/mongo" + + "gomodel/config" + "gomodel/internal/app" + "gomodel/internal/core" + "gomodel/internal/llmclient" + "gomodel/internal/providers" +) + +// TestServerConfig configures how the test server is set up. +type TestServerConfig struct { + // DBType is either "postgresql" or "mongodb" + DBType string + + // AuditLogEnabled enables audit logging + AuditLogEnabled bool + + // UsageEnabled enables usage tracking + UsageEnabled bool + + // LogBodies enables body logging in audit logs + LogBodies bool + + // LogHeaders enables header logging in audit logs + LogHeaders bool + + // OnlyModelInteractions limits logging to model endpoints only + OnlyModelInteractions bool +} + +// TestServerFixture holds test server resources. +type TestServerFixture struct { + // ServerURL is the base URL of the test server + ServerURL string + + // App is the running application + App *app.App + + // MockLLM is the mock LLM server + MockLLM *MockLLMServer + + // PgPool is the PostgreSQL connection pool (for DB assertions) + PgPool *pgxpool.Pool + + // MongoDb is the MongoDB database (for DB assertions) + MongoDb *mongo.Database + + // DBType is the configured database type + DBType string + + cancelFunc context.CancelFunc +} + +// SetupTestServer creates a test server with the specified configuration. +func SetupTestServer(t *testing.T, cfg TestServerConfig) *TestServerFixture { + t.Helper() + + ctx, cancel := context.WithCancel(GetTestContext()) + + // Create mock LLM server + mockLLM := NewMockLLMServer() + + // Find available port + port, err := findAvailablePort() + require.NoError(t, err, "failed to find available port") + + // Build app config + appCfg := buildAppConfig(t, cfg, mockLLM.URL(), port) + + // Create provider factory + factory := providers.NewProviderFactory() + testProvider := NewTestProvider(mockLLM.URL(), "sk-test-key") + factory.Register(providers.Registration{ + Type: "test", + New: func(_ string, _ llmclient.Hooks) core.Provider { return testProvider }, + }) + + // Create app + application, err := app.New(ctx, app.Config{ + AppConfig: appCfg, + Factory: factory, + RefreshInterval: 1 * time.Hour, // Don't refresh during tests + }) + require.NoError(t, err, "failed to create app") + + // Start server in background + serverURL := fmt.Sprintf("http://127.0.0.1:%d", port) + go func() { + addr := fmt.Sprintf("127.0.0.1:%d", port) + _ = application.Start(addr) + }() + + // Wait for server to be healthy + err = waitForServer(serverURL + "/health") + require.NoError(t, err, "server failed to become healthy") + + fixture := &TestServerFixture{ + ServerURL: serverURL, + App: application, + MockLLM: mockLLM, + DBType: cfg.DBType, + cancelFunc: cancel, + } + + // Set database references for assertions + switch cfg.DBType { + case "postgresql": + fixture.PgPool = GetPostgreSQLPool() + case "mongodb": + fixture.MongoDb = GetMongoDatabase() + } + + return fixture +} + +// FlushAndClose flushes all pending log entries and closes loggers. +// CRITICAL: Call this before making any DB assertions. +func (f *TestServerFixture) FlushAndClose(t *testing.T) { + t.Helper() + + // Close the app which flushes all loggers + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + if f.App != nil { + err := f.App.Shutdown(ctx) + require.NoError(t, err, "failed to shutdown app") + } +} + +// Shutdown gracefully shuts down the test server. +func (f *TestServerFixture) Shutdown(t *testing.T) { + t.Helper() + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + if f.App != nil { + _ = f.App.Shutdown(ctx) + } + + if f.MockLLM != nil { + f.MockLLM.Close() + } + + if f.cancelFunc != nil { + f.cancelFunc() + } +} + +// buildAppConfig creates an application config for testing. +func buildAppConfig(t *testing.T, cfg TestServerConfig, mockLLMURL string, port int) *config.Config { + t.Helper() + + appCfg := &config.Config{ + Server: config.ServerConfig{ + Port: fmt.Sprintf("%d", port), + // No master key for tests (unsafe mode) + }, + Cache: config.CacheConfig{ + Type: "local", + }, + Logging: config.LogConfig{ + Enabled: cfg.AuditLogEnabled, + LogBodies: cfg.LogBodies, + LogHeaders: cfg.LogHeaders, + BufferSize: 100, // Smaller buffer for faster flushing in tests + FlushInterval: 1, // 1 second flush interval + RetentionDays: 0, // No retention cleanup in tests + OnlyModelInteractions: cfg.OnlyModelInteractions, + }, + Usage: config.UsageConfig{ + Enabled: cfg.UsageEnabled, + EnforceReturningUsageData: true, + BufferSize: 100, + FlushInterval: 1, + RetentionDays: 0, + }, + Metrics: config.MetricsConfig{ + Enabled: false, + }, + Providers: map[string]config.ProviderConfig{ + "test": { + Type: "test", + APIKey: "sk-test-key", + BaseURL: mockLLMURL, + }, + }, + } + + // Configure storage based on DBType + switch cfg.DBType { + case "postgresql": + appCfg.Storage = config.StorageConfig{ + Type: "postgresql", + PostgreSQL: config.PostgreSQLStorageConfig{ + URL: GetPostgreSQLURL(), + MaxConns: 5, + }, + } + case "mongodb": + appCfg.Storage = config.StorageConfig{ + Type: "mongodb", + MongoDB: config.MongoDBStorageConfig{ + URL: GetMongoURL(), + Database: "gomodel_test", + }, + } + default: + t.Fatalf("unsupported DB type: %s", cfg.DBType) + } + + return appCfg +} + +// waitForServer waits for the server to become healthy. +func waitForServer(healthURL string) error { + client := &http.Client{Timeout: 2 * time.Second} + for i := 0; i < 50; i++ { + resp, err := client.Get(healthURL) + if err == nil { + _ = resp.Body.Close() + if resp.StatusCode == http.StatusOK { + return nil + } + } + time.Sleep(100 * time.Millisecond) + } + return fmt.Errorf("server did not become healthy within timeout") +} + +// findAvailablePort finds an available TCP port on loopback. +func findAvailablePort() (int, error) { + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + return 0, err + } + defer func() { _ = listener.Close() }() + return listener.Addr().(*net.TCPAddr).Port, nil +} + +// MockLLMServer is a mock LLM server for testing. +type MockLLMServer struct { + server *httptest.Server +} + +// NewMockLLMServer creates a new mock LLM server. +func NewMockLLMServer() *MockLLMServer { + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/v1/chat/completions": + handleChatCompletion(w, r) + case "/v1/responses": + handleResponses(w, r) + case "/v1/models": + handleModels(w) + default: + http.NotFound(w, r) + } + }) + + server := httptest.NewServer(handler) + return &MockLLMServer{server: server} +} + +// URL returns the server URL. +func (m *MockLLMServer) URL() string { + return m.server.URL +} + +// Close shuts down the server. +func (m *MockLLMServer) Close() { + m.server.Close() +} + +func handleChatCompletion(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + + response := `{ + "id": "chatcmpl-test123", + "object": "chat.completion", + "created": 1700000000, + "model": "gpt-4", + "choices": [{ + "index": 0, + "message": { + "role": "assistant", + "content": "Hello! How can I help you today?" + }, + "finish_reason": "stop" + }], + "usage": { + "prompt_tokens": 10, + "completion_tokens": 8, + "total_tokens": 18 + } + }` + _, _ = w.Write([]byte(response)) +} + +func handleResponses(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + + response := `{ + "id": "resp-test123", + "object": "response", + "created_at": 1700000000, + "model": "gpt-4", + "output": [{ + "type": "message", + "id": "msg-test123", + "role": "assistant", + "content": [{ + "type": "output_text", + "text": "Hello! How can I help you today?" + }] + }], + "usage": { + "input_tokens": 10, + "output_tokens": 8, + "total_tokens": 18 + } + }` + _, _ = w.Write([]byte(response)) +} + +func handleModels(w http.ResponseWriter) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + + response := `{ + "object": "list", + "data": [ + {"id": "gpt-4", "object": "model", "owned_by": "openai"}, + {"id": "gpt-4.1", "object": "model", "owned_by": "openai"}, + {"id": "gpt-3.5-turbo", "object": "model", "owned_by": "openai"} + ] + }` + _, _ = w.Write([]byte(response)) +} + +// TestProvider is a test provider that forwards requests to the mock LLM server. +type TestProvider struct { + baseURL string + apiKey string + httpClient *http.Client +} + +// NewTestProvider creates a new test provider. +func NewTestProvider(baseURL, apiKey string) *TestProvider { + return &TestProvider{ + baseURL: baseURL, + apiKey: apiKey, + httpClient: &http.Client{Timeout: 30 * time.Second}, + } +} + +// ChatCompletion forwards the request to the mock server. +func (p *TestProvider) ChatCompletion(ctx context.Context, req *core.ChatRequest) (*core.ChatResponse, error) { + return forwardChatRequest(ctx, p.httpClient, p.baseURL, p.apiKey, req) +} + +// StreamChatCompletion forwards the streaming request to the mock server. +func (p *TestProvider) StreamChatCompletion(ctx context.Context, req *core.ChatRequest) (io.ReadCloser, error) { + // For simplicity, return non-streaming response wrapped in ReadCloser + resp, err := p.ChatCompletion(ctx, req) + if err != nil { + return nil, err + } + _ = resp // Would need to convert to SSE format for real streaming tests + return nil, fmt.Errorf("streaming not implemented in test provider") +} + +// ListModels returns a mock list of models. +func (p *TestProvider) ListModels(ctx context.Context) (*core.ModelsResponse, error) { + return &core.ModelsResponse{ + Object: "list", + Data: []core.Model{ + {ID: "gpt-4.1", Object: "model", OwnedBy: "openai"}, + {ID: "gpt-4", Object: "model", OwnedBy: "openai"}, + {ID: "gpt-3.5-turbo", Object: "model", OwnedBy: "openai"}, + }, + }, nil +} + +// Responses forwards the responses API request to the mock server. +func (p *TestProvider) Responses(ctx context.Context, req *core.ResponsesRequest) (*core.ResponsesResponse, error) { + return forwardResponsesRequest(ctx, p.httpClient, p.baseURL, p.apiKey, req) +} + +// StreamResponses forwards the streaming responses API request to the mock server. +func (p *TestProvider) StreamResponses(ctx context.Context, req *core.ResponsesRequest) (io.ReadCloser, error) { + return nil, fmt.Errorf("streaming not implemented in test provider") +} diff --git a/tests/integration/usage_test.go b/tests/integration/usage_test.go new file mode 100644 index 00000000..638fe239 --- /dev/null +++ b/tests/integration/usage_test.go @@ -0,0 +1,253 @@ +//go:build integration + +package integration + +import ( + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "gomodel/tests/integration/dbassert" +) + +func TestUsage_CapturesAllFields_PostgreSQL(t *testing.T) { + fixture := SetupTestServer(t, TestServerConfig{ + DBType: "postgresql", + AuditLogEnabled: false, + UsageEnabled: true, + OnlyModelInteractions: false, + }) + + // Generate unique request ID + requestID := uuid.New().String() + + // Make HTTP request + payload := newChatRequest("gpt-4", "Hello, world!") + resp := sendChatRequestWithHeaders(t, fixture.ServerURL, payload, map[string]string{ + "X-Request-ID": requestID, + }) + require.Equal(t, 200, resp.StatusCode) + closeBody(resp) + + // CRITICAL: Flush before querying DB + fixture.FlushAndClose(t) + + // Query and assert DB state + entries := dbassert.QueryUsageByRequestID(t, fixture.PgPool, requestID) + require.Len(t, entries, 1, "expected exactly one usage entry") + + entry := entries[0] + + // Assert field completeness + dbassert.AssertUsageFieldCompleteness(t, entry) + + // Assert specific values + dbassert.AssertUsageMatches(t, dbassert.ExpectedUsage{ + Model: "gpt-4", + Provider: "test", + Endpoint: "chat", + RequestID: requestID, + }, entry) + + // Assert token counts are populated + dbassert.AssertUsageHasTokens(t, entry) + + // Verify token values from mock response + assert.Equal(t, 10, entry.InputTokens, "input tokens mismatch") + assert.Equal(t, 8, entry.OutputTokens, "output tokens mismatch") + assert.Equal(t, 18, entry.TotalTokens, "total tokens mismatch") + + // Assert token consistency + dbassert.AssertUsageTokensConsistent(t, entry) +} + +func TestUsage_CapturesAllFields_MongoDB(t *testing.T) { + fixture := SetupTestServer(t, TestServerConfig{ + DBType: "mongodb", + AuditLogEnabled: false, + UsageEnabled: true, + OnlyModelInteractions: false, + }) + + requestID := uuid.New().String() + + payload := newChatRequest("gpt-4", "Hello, world!") + resp := sendChatRequestWithHeaders(t, fixture.ServerURL, payload, map[string]string{ + "X-Request-ID": requestID, + }) + require.Equal(t, 200, resp.StatusCode) + closeBody(resp) + + fixture.FlushAndClose(t) + + entries := dbassert.QueryUsageByRequestIDMongo(t, fixture.MongoDb, requestID) + require.Len(t, entries, 1, "expected exactly one usage entry") + + entry := entries[0] + + dbassert.AssertUsageFieldCompleteness(t, entry) + dbassert.AssertUsageMatches(t, dbassert.ExpectedUsage{ + Model: "gpt-4", + Provider: "test", + Endpoint: "chat", + RequestID: requestID, + }, entry) +} + +func TestUsage_ResponsesEndpoint_PostgreSQL(t *testing.T) { + fixture := SetupTestServer(t, TestServerConfig{ + DBType: "postgresql", + AuditLogEnabled: false, + UsageEnabled: true, + OnlyModelInteractions: false, + }) + + requestID := uuid.New().String() + + payload := newResponsesRequest("gpt-4", "Hello!") + resp := sendResponsesRequestWithHeaders(t, fixture.ServerURL, payload, map[string]string{ + "X-Request-ID": requestID, + }) + require.Equal(t, 200, resp.StatusCode) + closeBody(resp) + + fixture.FlushAndClose(t) + + entries := dbassert.QueryUsageByRequestID(t, fixture.PgPool, requestID) + require.Len(t, entries, 1) + + dbassert.AssertUsageMatches(t, dbassert.ExpectedUsage{ + Model: "gpt-4", + Endpoint: "responses", + RequestID: requestID, + }, entries[0]) +} + +func TestUsage_MultipleRequests_PostgreSQL(t *testing.T) { + fixture := SetupTestServer(t, TestServerConfig{ + DBType: "postgresql", + AuditLogEnabled: false, + UsageEnabled: true, + OnlyModelInteractions: false, + }) + + // Clear existing entries + dbassert.ClearUsage(t, fixture.PgPool) + + requestIDs := make([]string, 5) + for i := 0; i < 5; i++ { + requestIDs[i] = uuid.New().String() + payload := newChatRequest("gpt-4", "Hello!") + resp := sendChatRequestWithHeaders(t, fixture.ServerURL, payload, map[string]string{ + "X-Request-ID": requestIDs[i], + }) + require.Equal(t, 200, resp.StatusCode) + closeBody(resp) + } + + fixture.FlushAndClose(t) + + // Verify each request has its own usage entry + for _, reqID := range requestIDs { + entries := dbassert.QueryUsageByRequestID(t, fixture.PgPool, reqID) + require.Len(t, entries, 1, "expected one usage entry for request ID %s", reqID) + } + + // Verify total count + totalCount := dbassert.CountUsage(t, fixture.PgPool) + assert.GreaterOrEqual(t, totalCount, 5, "expected at least 5 usage entries") +} + +func TestUsage_TokenSummary_PostgreSQL(t *testing.T) { + fixture := SetupTestServer(t, TestServerConfig{ + DBType: "postgresql", + AuditLogEnabled: false, + UsageEnabled: true, + OnlyModelInteractions: false, + }) + + // Clear existing entries + dbassert.ClearUsage(t, fixture.PgPool) + + // Make several requests + for i := 0; i < 3; i++ { + payload := newChatRequest("gpt-4", "Hello!") + resp := sendChatRequest(t, fixture.ServerURL, payload) + require.Equal(t, 200, resp.StatusCode) + closeBody(resp) + } + + fixture.FlushAndClose(t) + + // Get token summary + summary := dbassert.SumTokensByModel(t, fixture.PgPool) + + // Verify gpt-4 tokens (3 requests * 18 total tokens each = 54 total) + gpt4Summary, ok := summary["gpt-4"] + require.True(t, ok, "expected summary for gpt-4 model") + + assert.Equal(t, int64(3), gpt4Summary.RequestCount, "expected 3 requests") + assert.Equal(t, int64(30), gpt4Summary.InputTokens, "expected 30 input tokens (3 * 10)") + assert.Equal(t, int64(24), gpt4Summary.OutputTokens, "expected 24 output tokens (3 * 8)") + assert.Equal(t, int64(54), gpt4Summary.TotalTokens, "expected 54 total tokens (3 * 18)") +} + +func TestUsage_ProviderID_PostgreSQL(t *testing.T) { + fixture := SetupTestServer(t, TestServerConfig{ + DBType: "postgresql", + AuditLogEnabled: false, + UsageEnabled: true, + OnlyModelInteractions: false, + }) + + requestID := uuid.New().String() + + payload := newChatRequest("gpt-4", "Hello!") + resp := sendChatRequestWithHeaders(t, fixture.ServerURL, payload, map[string]string{ + "X-Request-ID": requestID, + }) + require.Equal(t, 200, resp.StatusCode) + closeBody(resp) + + fixture.FlushAndClose(t) + + entries := dbassert.QueryUsageByRequestID(t, fixture.PgPool, requestID) + require.Len(t, entries, 1) + + // The mock LLM returns "chatcmpl-test123" as the response ID + assert.Equal(t, "chatcmpl-test123", entries[0].ProviderID, "expected provider ID from mock response") +} + +func TestUsage_BothAuditAndUsage_PostgreSQL(t *testing.T) { + fixture := SetupTestServer(t, TestServerConfig{ + DBType: "postgresql", + AuditLogEnabled: true, + UsageEnabled: true, + LogBodies: true, + OnlyModelInteractions: false, + }) + + requestID := uuid.New().String() + + payload := newChatRequest("gpt-4", "Hello!") + resp := sendChatRequestWithHeaders(t, fixture.ServerURL, payload, map[string]string{ + "X-Request-ID": requestID, + }) + require.Equal(t, 200, resp.StatusCode) + closeBody(resp) + + fixture.FlushAndClose(t) + + // Both tables should have entries + auditEntries := dbassert.QueryAuditLogsByRequestID(t, fixture.PgPool, requestID) + require.Len(t, auditEntries, 1, "expected audit log entry") + + usageEntries := dbassert.QueryUsageByRequestID(t, fixture.PgPool, requestID) + require.Len(t, usageEntries, 1, "expected usage entry") + + // They should share the same request ID + assert.Equal(t, requestID, auditEntries[0].RequestID) + assert.Equal(t, requestID, usageEntries[0].RequestID) +} From e503896bf95958c7c83155df4cc0f4adb0912bed Mon Sep 17 00:00:00 2001 From: "Jakub A. W" Date: Mon, 26 Jan 2026 15:14:41 +0100 Subject: [PATCH 2/8] fix: pass provider parameter to usage extractors The usage tracking integration tests were failing because: 1. Provider field was empty - extractors read from response object which doesn't include provider info from upstream APIs 2. Endpoint format mismatch - tests expected short names but handlers pass full HTTP paths Changes: - Add provider parameter to ExtractFromChatResponse and ExtractFromResponsesResponse functions - Update handlers to pass providerType to extractors - Update test expectations to use full endpoint paths - Fix MongoDB timestamp parsing in test helpers (bson.DateTime) Co-Authored-By: Claude Opus 4.5 --- internal/server/handlers.go | 4 +-- internal/usage/extractor.go | 8 ++--- internal/usage/extractor_test.go | 46 ++++++++++++++------------ tests/integration/dbassert/auditlog.go | 2 ++ tests/integration/dbassert/usage.go | 2 ++ tests/integration/usage_test.go | 7 ++-- 6 files changed, 39 insertions(+), 30 deletions(-) diff --git a/internal/server/handlers.go b/internal/server/handlers.go index 8b268bcc..ac1f8551 100644 --- a/internal/server/handlers.go +++ b/internal/server/handlers.go @@ -119,7 +119,7 @@ func (h *Handler) ChatCompletion(c echo.Context) error { // Track usage if enabled (reuses requestID from context enrichment above) if h.usageLogger != nil && h.usageLogger.Config().Enabled { - usageEntry := usage.ExtractFromChatResponse(resp, requestID, "/v1/chat/completions") + usageEntry := usage.ExtractFromChatResponse(resp, requestID, providerType, "/v1/chat/completions") if usageEntry != nil { h.usageLogger.Write(usageEntry) } @@ -192,7 +192,7 @@ func (h *Handler) Responses(c echo.Context) error { // Track usage if enabled (reuses requestID from context enrichment above) if h.usageLogger != nil && h.usageLogger.Config().Enabled { - usageEntry := usage.ExtractFromResponsesResponse(resp, requestID, "/v1/responses") + usageEntry := usage.ExtractFromResponsesResponse(resp, requestID, providerType, "/v1/responses") if usageEntry != nil { h.usageLogger.Write(usageEntry) } diff --git a/internal/usage/extractor.go b/internal/usage/extractor.go index cd6d54c2..b5c01e82 100644 --- a/internal/usage/extractor.go +++ b/internal/usage/extractor.go @@ -10,7 +10,7 @@ import ( // ExtractFromChatResponse extracts usage data from a ChatResponse. // It normalizes the usage data into a UsageEntry and preserves raw extended data. -func ExtractFromChatResponse(resp *core.ChatResponse, requestID, endpoint string) *UsageEntry { +func ExtractFromChatResponse(resp *core.ChatResponse, requestID, provider, endpoint string) *UsageEntry { if resp == nil { return nil } @@ -21,7 +21,7 @@ func ExtractFromChatResponse(resp *core.ChatResponse, requestID, endpoint string ProviderID: resp.ID, Timestamp: time.Now().UTC(), Model: resp.Model, - Provider: resp.Provider, + Provider: provider, Endpoint: endpoint, InputTokens: resp.Usage.PromptTokens, OutputTokens: resp.Usage.CompletionTokens, @@ -51,7 +51,7 @@ func cloneRawData(src map[string]any) map[string]any { // ExtractFromResponsesResponse extracts usage data from a ResponsesResponse. // It normalizes the usage data into a UsageEntry and preserves raw extended data. -func ExtractFromResponsesResponse(resp *core.ResponsesResponse, requestID, endpoint string) *UsageEntry { +func ExtractFromResponsesResponse(resp *core.ResponsesResponse, requestID, provider, endpoint string) *UsageEntry { if resp == nil { return nil } @@ -62,7 +62,7 @@ func ExtractFromResponsesResponse(resp *core.ResponsesResponse, requestID, endpo ProviderID: resp.ID, Timestamp: time.Now().UTC(), Model: resp.Model, - Provider: resp.Provider, + Provider: provider, Endpoint: endpoint, } diff --git a/internal/usage/extractor_test.go b/internal/usage/extractor_test.go index ee03d673..28928f49 100644 --- a/internal/usage/extractor_test.go +++ b/internal/usage/extractor_test.go @@ -11,6 +11,7 @@ func TestExtractFromChatResponse(t *testing.T) { name string resp *core.ChatResponse requestID string + provider string endpoint string wantNil bool wantInput int @@ -21,16 +22,16 @@ func TestExtractFromChatResponse(t *testing.T) { wantModel string }{ { - name: "nil response", - resp: nil, - wantNil: true, + name: "nil response", + resp: nil, + provider: "openai", + wantNil: true, }, { name: "basic response", resp: &core.ChatResponse{ - ID: "chatcmpl-123", - Model: "gpt-4", - Provider: "openai", + ID: "chatcmpl-123", + Model: "gpt-4", Usage: core.Usage{ PromptTokens: 100, CompletionTokens: 50, @@ -38,6 +39,7 @@ func TestExtractFromChatResponse(t *testing.T) { }, }, requestID: "req-123", + provider: "openai", endpoint: "/v1/chat/completions", wantInput: 100, wantOutput: 50, @@ -48,9 +50,8 @@ func TestExtractFromChatResponse(t *testing.T) { { name: "response with raw usage", resp: &core.ChatResponse{ - ID: "chatcmpl-456", - Model: "gpt-4o", - Provider: "openai", + ID: "chatcmpl-456", + Model: "gpt-4o", Usage: core.Usage{ PromptTokens: 200, CompletionTokens: 100, @@ -62,6 +63,7 @@ func TestExtractFromChatResponse(t *testing.T) { }, }, requestID: "req-456", + provider: "openai", endpoint: "/v1/chat/completions", wantInput: 200, wantOutput: 100, @@ -74,7 +76,7 @@ func TestExtractFromChatResponse(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - entry := ExtractFromChatResponse(tt.resp, tt.requestID, tt.endpoint) + entry := ExtractFromChatResponse(tt.resp, tt.requestID, tt.provider, tt.endpoint) if tt.wantNil { if entry != nil { @@ -123,6 +125,7 @@ func TestExtractFromResponsesResponse(t *testing.T) { name string resp *core.ResponsesResponse requestID string + provider string endpoint string wantNil bool wantInput int @@ -130,19 +133,20 @@ func TestExtractFromResponsesResponse(t *testing.T) { wantTotal int }{ { - name: "nil response", - resp: nil, - wantNil: true, + name: "nil response", + resp: nil, + provider: "openai", + wantNil: true, }, { name: "response with nil usage", resp: &core.ResponsesResponse{ - ID: "resp-123", - Model: "gpt-4", - Provider: "openai", - Usage: nil, + ID: "resp-123", + Model: "gpt-4", + Usage: nil, }, requestID: "req-123", + provider: "openai", endpoint: "/v1/responses", wantInput: 0, wantOutput: 0, @@ -151,9 +155,8 @@ func TestExtractFromResponsesResponse(t *testing.T) { { name: "response with usage", resp: &core.ResponsesResponse{ - ID: "resp-456", - Model: "gpt-4", - Provider: "openai", + ID: "resp-456", + Model: "gpt-4", Usage: &core.ResponsesUsage{ InputTokens: 100, OutputTokens: 50, @@ -161,6 +164,7 @@ func TestExtractFromResponsesResponse(t *testing.T) { }, }, requestID: "req-456", + provider: "openai", endpoint: "/v1/responses", wantInput: 100, wantOutput: 50, @@ -170,7 +174,7 @@ func TestExtractFromResponsesResponse(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - entry := ExtractFromResponsesResponse(tt.resp, tt.requestID, tt.endpoint) + entry := ExtractFromResponsesResponse(tt.resp, tt.requestID, tt.provider, tt.endpoint) if tt.wantNil { if entry != nil { diff --git a/tests/integration/dbassert/auditlog.go b/tests/integration/dbassert/auditlog.go index e9c3da10..afda8bbe 100644 --- a/tests/integration/dbassert/auditlog.go +++ b/tests/integration/dbassert/auditlog.go @@ -122,6 +122,8 @@ func bsonToAuditLogEntry(t *testing.T, doc bson.M) AuditLogEntry { } if v, ok := doc["timestamp"].(time.Time); ok { entry.Timestamp = v + } else if v, ok := doc["timestamp"].(bson.DateTime); ok { + entry.Timestamp = v.Time() } if v, ok := doc["duration_ns"].(int64); ok { entry.DurationNs = v diff --git a/tests/integration/dbassert/usage.go b/tests/integration/dbassert/usage.go index 75378948..944f9995 100644 --- a/tests/integration/dbassert/usage.go +++ b/tests/integration/dbassert/usage.go @@ -170,6 +170,8 @@ func bsonToUsageEntry(t *testing.T, doc bson.M) UsageEntry { } if v, ok := doc["timestamp"].(time.Time); ok { entry.Timestamp = v + } else if v, ok := doc["timestamp"].(bson.DateTime); ok { + entry.Timestamp = v.Time() } if v, ok := doc["model"].(string); ok { entry.Model = v diff --git a/tests/integration/usage_test.go b/tests/integration/usage_test.go index 638fe239..6b0ccdb7 100644 --- a/tests/integration/usage_test.go +++ b/tests/integration/usage_test.go @@ -47,7 +47,7 @@ func TestUsage_CapturesAllFields_PostgreSQL(t *testing.T) { dbassert.AssertUsageMatches(t, dbassert.ExpectedUsage{ Model: "gpt-4", Provider: "test", - Endpoint: "chat", + Endpoint: "/v1/chat/completions", RequestID: requestID, }, entry) @@ -91,7 +91,7 @@ func TestUsage_CapturesAllFields_MongoDB(t *testing.T) { dbassert.AssertUsageMatches(t, dbassert.ExpectedUsage{ Model: "gpt-4", Provider: "test", - Endpoint: "chat", + Endpoint: "/v1/chat/completions", RequestID: requestID, }, entry) } @@ -120,7 +120,8 @@ func TestUsage_ResponsesEndpoint_PostgreSQL(t *testing.T) { dbassert.AssertUsageMatches(t, dbassert.ExpectedUsage{ Model: "gpt-4", - Endpoint: "responses", + Provider: "test", + Endpoint: "/v1/responses", RequestID: requestID, }, entries[0]) } From 5373661a0f3a256872268bba9e9c7be957b4fbc3 Mon Sep 17 00:00:00 2001 From: "Jakub A. W" Date: Mon, 26 Jan 2026 15:41:30 +0100 Subject: [PATCH 3/8] docs: fixed MarkDown files --- docs/FUTURE_ARCHITECTURE.md | 2 ++ docs/TESTING_STRATEGY.md | 38 ++++++++++++++++++-------------- tests/contract/README.md | 44 ++++++++++++++++++------------------- tests/contract/groq_test.go | 3 ++- tests/contract/xai_test.go | 3 ++- 5 files changed, 49 insertions(+), 41 deletions(-) diff --git a/docs/FUTURE_ARCHITECTURE.md b/docs/FUTURE_ARCHITECTURE.md index 4b0d6253..b18be8c6 100644 --- a/docs/FUTURE_ARCHITECTURE.md +++ b/docs/FUTURE_ARCHITECTURE.md @@ -1,3 +1,5 @@ +# Future Architecture + ``` ┌─────────────────────────────────────────────────────────────────────────────────────────┐ │ INGRESS LAYER │ diff --git a/docs/TESTING_STRATEGY.md b/docs/TESTING_STRATEGY.md index ed9a2338..7d5a7dd2 100644 --- a/docs/TESTING_STRATEGY.md +++ b/docs/TESTING_STRATEGY.md @@ -2,7 +2,7 @@ A 3-layer testing strategy with **DB state verification** as the highest priority: -``` +```text ┌─────────────────────────────────────────────────────────────┐ │ Layer 3: Contract Tests (Provider API Compatibility) │ │ - Golden files with real API responses │ @@ -72,6 +72,7 @@ go test -v -tags=e2e ./tests/e2e/... -run TestName ``` **Key characteristics:** + - Tests full request flow through the gateway - Uses mock providers (no real API calls) - Validates routing, transformation, and response handling @@ -87,12 +88,14 @@ go test -v -tags=integration ./tests/integration/... ``` **Key characteristics:** + - Real PostgreSQL/MongoDB via testcontainers - Verify DB state after each request - Field completeness assertions - Validates audit logging, usage tracking **Focus areas:** + - Audit log entries are complete and accurate - Usage metrics are properly recorded - Request/response pairs are correctly stored @@ -111,6 +114,7 @@ go test -v -tags=contract ./tests/contract/... -run TestOpenAI ``` **Key characteristics:** + - Golden files contain real API responses (recorded manually) - Tests validate response structure, not content - No network calls during test execution @@ -118,13 +122,13 @@ go test -v -tags=contract ./tests/contract/... -run TestOpenAI ### Supported Providers -| Provider | Endpoint | Features Tested | -|----------|----------|-----------------| -| OpenAI | api.openai.com | Chat, streaming, models, tools, JSON mode, multimodal, reasoning | -| Anthropic | api.anthropic.com | Messages, streaming, tools, extended thinking, multimodal | -| Gemini | generativelanguage.googleapis.com | Chat, streaming, models, tools (OpenAI-compatible) | -| xAI | api.x.ai | Chat, streaming, models (OpenAI-compatible) | -| Groq | api.groq.com | Chat, streaming, models, tools (OpenAI-compatible) | +| Provider | Endpoint | Features Tested | +| --------- | --------------------------------- | ---------------------------------------------------------------- | +| OpenAI | api.openai.com | Chat, streaming, models, tools, JSON mode, multimodal, reasoning | +| Anthropic | api.anthropic.com | Messages, streaming, tools, extended thinking, multimodal | +| Gemini | generativelanguage.googleapis.com | Chat, streaming, models, tools (OpenAI-compatible) | +| xAI | api.x.ai | Chat, streaming, models (OpenAI-compatible) | +| Groq | api.groq.com | Chat, streaming, models, tools (OpenAI-compatible) | ### Golden File Structure @@ -187,14 +191,14 @@ make lint && make test-all ## Test Commands Reference -| Command | Description | -|---------|-------------| -| `make test` | Unit tests only | -| `make test-e2e` | E2E tests with mock providers | -| `make test-all` | Unit + E2E tests | -| `go test -tags=contract ./tests/contract/...` | Contract tests | -| `go test -tags=integration ./tests/integration/...` | Integration tests | -| `make lint` | Run golangci-lint | +| Command | Description | +| --------------------------------------------------- | ----------------------------- | +| `make test` | Unit tests only | +| `make test-e2e` | E2E tests with mock providers | +| `make test-all` | Unit + E2E tests | +| `go test -tags=contract ./tests/contract/...` | Contract tests | +| `go test -tags=integration ./tests/integration/...` | Integration tests | +| `make lint` | Run golangci-lint | ## CI/CD Integration @@ -207,7 +211,7 @@ jobs: - uses: actions/checkout@v4 - uses: actions/setup-go@v5 with: - go-version: '1.24' + go-version: "1.24" # Unit + E2E (no external dependencies) - run: make test-all diff --git a/tests/contract/README.md b/tests/contract/README.md index 760f54b0..1357dc99 100644 --- a/tests/contract/README.md +++ b/tests/contract/README.md @@ -4,7 +4,7 @@ Contract tests validate API response structures against recorded golden files. T ## Golden Files Structure -``` +```text testdata/ ├── openai/ │ ├── chat_completion.json # Basic chat completion @@ -393,27 +393,27 @@ curl https://api.groq.com/openai/v1/chat/completions \ ## Models Used -| Provider | Model | Notes | -|----------|-------|-------| -| OpenAI | gpt-4o-mini | Standard chat model | -| OpenAI | o3-mini | Reasoning model with thinking tokens | -| Anthropic | claude-sonnet-4-20250514 | Latest Claude model | -| Gemini | gemini-2.5-flash-preview-09-2025 | Preview flash model | -| xAI | grok-3-mini | Latest Grok model | -| Groq | llama-3.3-70b-versatile | Fast inference Llama model | +| Provider | Model | Notes | +| --------- | -------------------------------- | ------------------------------------ | +| OpenAI | gpt-4o-mini | Standard chat model | +| OpenAI | o3-mini | Reasoning model with thinking tokens | +| Anthropic | claude-sonnet-4-20250514 | Latest Claude model | +| Gemini | gemini-2.5-flash-preview-09-2025 | Preview flash model | +| xAI | grok-3-mini | Latest Grok model | +| Groq | llama-3.3-70b-versatile | Fast inference Llama model | ## Test Coverage -| Feature | OpenAI | Anthropic | Gemini | xAI | Groq | -|---------|--------|-----------|--------|-----|------| -| Basic chat | ✅ | ✅ | ✅ | ✅ | ✅ | -| Streaming | ✅ | ✅ | ✅ | ✅ | ✅ | -| Models list | ✅ | - | ✅ | ✅ | ✅ | -| Tool calling | ✅ | ✅ | ✅ | - | ✅ | -| System prompt | ✅ | ✅ | ✅ | ✅ | ✅ | -| Temperature | ✅ | ✅ | ✅ | ✅ | ✅ | -| Stop sequences | ✅ | ✅ | - | - | - | -| Multi-turn | ✅ | ✅ | - | - | - | -| Multimodal | ✅ | ✅ | - | - | - | -| JSON mode | ✅ | - | - | - | - | -| Reasoning | ✅ | ✅ | - | - | - | +| Feature | OpenAI | Anthropic | Gemini | xAI | Groq | +| -------------- | ------ | --------- | ------ | --- | ---- | +| Basic chat | ✅ | ✅ | ✅ | ✅ | ✅ | +| Streaming | ✅ | ✅ | ✅ | ✅ | ✅ | +| Models list | ✅ | - | ✅ | ✅ | ✅ | +| Tool calling | ✅ | ✅ | ✅ | - | ✅ | +| System prompt | ✅ | ✅ | ✅ | ✅ | ✅ | +| Temperature | ✅ | ✅ | ✅ | ✅ | ✅ | +| Stop sequences | ✅ | ✅ | - | - | - | +| Multi-turn | ✅ | ✅ | - | - | - | +| Multimodal | ✅ | ✅ | - | - | - | +| JSON mode | ✅ | - | - | - | - | +| Reasoning | ✅ | ✅ | - | - | - | diff --git a/tests/contract/groq_test.go b/tests/contract/groq_test.go index 1d10160c..065b3e33 100644 --- a/tests/contract/groq_test.go +++ b/tests/contract/groq_test.go @@ -3,6 +3,7 @@ package contract import ( + "strings" "testing" "github.com/stretchr/testify/assert" @@ -79,7 +80,7 @@ func TestGroq_ModelsResponse_Contract(t *testing.T) { // At least one Llama model should exist hasLlama := false for id := range modelIDs { - if len(id) >= 5 && id[:5] == "llama" { + if strings.HasPrefix(id, "llama") { hasLlama = true break } diff --git a/tests/contract/xai_test.go b/tests/contract/xai_test.go index 866e212f..119c57c9 100644 --- a/tests/contract/xai_test.go +++ b/tests/contract/xai_test.go @@ -3,6 +3,7 @@ package contract import ( + "strings" "testing" "github.com/stretchr/testify/assert" @@ -74,7 +75,7 @@ func TestXAI_ModelsResponse_Contract(t *testing.T) { // At least one Grok model should exist hasGrok := false for id := range modelIDs { - if len(id) >= 4 && id[:4] == "grok" { + if strings.HasPrefix(id, "grok") { hasGrok = true break } From 71ac30762f17aeaa582b41619c0967a6873dd996 Mon Sep 17 00:00:00 2001 From: "Jakub A. W" Date: Mon, 26 Jan 2026 15:41:59 +0100 Subject: [PATCH 4/8] refactor: refactored hasPrefixIgnoreModels functionk --- tests/contract/gemini_test.go | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/tests/contract/gemini_test.go b/tests/contract/gemini_test.go index e7b89dab..837ff387 100644 --- a/tests/contract/gemini_test.go +++ b/tests/contract/gemini_test.go @@ -3,6 +3,7 @@ package contract import ( + "strings" "testing" "github.com/stretchr/testify/assert" @@ -78,7 +79,7 @@ func TestGemini_ModelsResponse_Contract(t *testing.T) { // At least one Gemini model should exist hasGemini := false for id := range modelIDs { - if containsIgnorePrefix(id, "gemini") { + if hasPrefixIgnoreModels(id, "gemini") { hasGemini = true break } @@ -139,11 +140,8 @@ func TestGemini_ChatWithTools(t *testing.T) { }) } -// containsIgnorePrefix checks if a string contains a substring, ignoring "models/" prefix -func containsIgnorePrefix(s, substr string) bool { - // Strip common prefixes - if len(s) > 7 && s[:7] == "models/" { - s = s[7:] - } - return len(s) >= len(substr) && s[:len(substr)] == substr +// hasPrefixIgnoreModels checks if a string has a given prefix, after stripping "models/" prefix +func hasPrefixIgnoreModels(s, prefix string) bool { + s = strings.TrimPrefix(s, "models/") + return strings.HasPrefix(s, prefix) } From 06fc874c341329f624ac7b6c66e76bb4b26326d2 Mon Sep 17 00:00:00 2001 From: "Jakub A. W" Date: Mon, 26 Jan 2026 15:45:04 +0100 Subject: [PATCH 5/8] sec: upgraded dependencies --- go.mod | 47 +++++++++-------- go.sum | 157 ++++++++++++++++++++++++--------------------------------- 2 files changed, 93 insertions(+), 111 deletions(-) diff --git a/go.mod b/go.mod index de76b00b..24debf57 100644 --- a/go.mod +++ b/go.mod @@ -13,58 +13,62 @@ require ( github.com/redis/go-redis/v9 v9.17.2 github.com/spf13/viper v1.21.0 github.com/stretchr/testify v1.11.1 - github.com/testcontainers/testcontainers-go v0.34.0 - github.com/testcontainers/testcontainers-go/modules/mongodb v0.34.0 - github.com/testcontainers/testcontainers-go/modules/postgres v0.34.0 + github.com/testcontainers/testcontainers-go v0.40.0 + github.com/testcontainers/testcontainers-go/modules/mongodb v0.40.0 + github.com/testcontainers/testcontainers-go/modules/postgres v0.40.0 go.mongodb.org/mongo-driver/v2 v2.4.1 modernc.org/sqlite v1.44.2 ) require ( - dario.cat/mergo v1.0.0 // indirect + dario.cat/mergo v1.0.2 // indirect github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 // indirect github.com/Microsoft/go-winio v0.6.2 // indirect github.com/beorn7/perks v1.0.1 // indirect - github.com/cenkalti/backoff/v4 v4.2.1 // indirect + github.com/cenkalti/backoff/v4 v4.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect - github.com/containerd/containerd v1.7.18 // indirect + github.com/containerd/errdefs v1.0.0 // indirect + github.com/containerd/errdefs/pkg v0.3.0 // indirect github.com/containerd/log v0.1.0 // indirect github.com/containerd/platforms v0.2.1 // indirect github.com/cpuguy83/dockercfg v0.3.2 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/distribution/reference v0.6.0 // indirect - github.com/docker/docker v27.1.1+incompatible // indirect - github.com/docker/go-connections v0.5.0 // indirect + github.com/docker/docker v28.5.2+incompatible // indirect + github.com/docker/go-connections v0.6.0 // indirect github.com/docker/go-units v0.5.0 // indirect github.com/dustin/go-humanize v1.0.1 // indirect + github.com/ebitengine/purego v0.8.4 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect - github.com/go-logr/logr v1.4.1 // indirect + github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-ole/go-ole v1.2.6 // indirect - github.com/gogo/protobuf v1.3.2 // indirect github.com/golang/snappy v1.0.0 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.16.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect - github.com/klauspost/compress v1.18.0 // indirect + github.com/klauspost/compress v1.18.2 // indirect github.com/kylelemons/godebug v1.1.0 // indirect github.com/labstack/gommon v0.4.2 // indirect github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect - github.com/magiconair/properties v1.8.7 // indirect + github.com/magiconair/properties v1.8.10 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/moby/docker-image-spec v1.3.1 // indirect + github.com/moby/go-archive v0.2.0 // indirect github.com/moby/patternmatcher v0.6.0 // indirect - github.com/moby/sys/sequential v0.5.0 // indirect - github.com/moby/sys/user v0.1.0 // indirect + github.com/moby/sys/sequential v0.6.0 // indirect + github.com/moby/sys/user v0.4.0 // indirect + github.com/moby/sys/userns v0.1.0 // indirect github.com/moby/term v0.5.0 // indirect github.com/morikuni/aec v1.0.0 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect - github.com/opencontainers/image-spec v1.1.0 // indirect + github.com/opencontainers/image-spec v1.1.1 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect @@ -74,8 +78,7 @@ require ( github.com/prometheus/procfs v0.19.2 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/sagikazarmark/locafero v0.12.0 // indirect - github.com/shirou/gopsutil/v3 v3.23.12 // indirect - github.com/shoenig/go-m1cpu v0.1.6 // indirect + github.com/shirou/gopsutil/v4 v4.25.6 // indirect github.com/sirupsen/logrus v1.9.3 // indirect github.com/spf13/afero v1.15.0 // indirect github.com/spf13/cast v1.10.0 // indirect @@ -89,11 +92,13 @@ require ( github.com/xdg-go/scram v1.1.2 // indirect github.com/xdg-go/stringprep v1.0.4 // indirect github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect - github.com/yusufpapurcu/wmi v1.2.3 // indirect + github.com/yusufpapurcu/wmi v1.2.4 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 // indirect - go.opentelemetry.io/otel v1.24.0 // indirect - go.opentelemetry.io/otel/metric v1.24.0 // indirect - go.opentelemetry.io/otel/trace v1.24.0 // indirect + go.opentelemetry.io/otel v1.39.0 // indirect + go.opentelemetry.io/otel/metric v1.39.0 // indirect + go.opentelemetry.io/otel/sdk v1.21.0 // indirect + go.opentelemetry.io/otel/trace v1.39.0 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/crypto v0.46.0 // indirect diff --git a/go.sum b/go.sum index 155843d2..31bd72a9 100644 --- a/go.sum +++ b/go.sum @@ -1,7 +1,7 @@ -dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk= -dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= -github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24 h1:bvDV9vkmnHYOMsOr4WLk+Vo07yKIzd94sVoIqshQ4bU= -github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8= +dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= +dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= +github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk= +github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8= github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 h1:UQHMgLO+TxOElx5B5HZ4hJQsoJ/PvUvKRhJHDQXO8P8= github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= @@ -14,12 +14,14 @@ github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= -github.com/cenkalti/backoff/v4 v4.2.1 h1:y4OZtCnogmCPw98Zjyt5a6+QwPLGkiQsYW5oUqylYbM= -github.com/cenkalti/backoff/v4 v4.2.1/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= +github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/containerd/containerd v1.7.18 h1:jqjZTQNfXGoEaZdW1WwPU0RqSn1Bm2Ay/KJPUuO8nao= -github.com/containerd/containerd v1.7.18/go.mod h1:IYEk9/IO6wAPUz2bCMVUbsfXjzw5UNP5fLz4PsUygQ4= +github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= +github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= +github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= +github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A= @@ -35,14 +37,16 @@ github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/r github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= -github.com/docker/docker v27.1.1+incompatible h1:hO/M4MtV36kzKldqnA37IWhebRA+LnqqcqDja6kVaKY= -github.com/docker/docker v27.1.1+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= -github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c= -github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc= +github.com/docker/docker v28.5.2+incompatible h1:DBX0Y0zAjZbSrm1uzOkdr1onVghKaftjlSWt4AFexzM= +github.com/docker/docker v28.5.2+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94= +github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE= github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/ebitengine/purego v0.8.4 h1:CF7LEKg5FFOsASUj0+QwaXf8Ht6TlFxg09+S9wz0omw= +github.com/ebitengine/purego v0.8.4/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= @@ -50,21 +54,17 @@ github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7z github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= -github.com/go-logr/logr v1.4.1 h1:pKouT5E8xu9zeFC39JXRDukb6JFQPXM5p5I91188VAQ= -github.com/go-logr/logr v1.4.1/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro= github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= -github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= -github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs= github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= -github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= @@ -85,10 +85,8 @@ github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= -github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= -github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= -github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= -github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk= +github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= @@ -103,24 +101,30 @@ github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4= github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= -github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= -github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE= +github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mdelapenya/tlscert v0.2.0 h1:7H81W6Z/4weDvZBNOfQte5GpIMo0lGYEeWbkGp5LJHI= +github.com/mdelapenya/tlscert v0.2.0/go.mod h1:O4njj3ELLnJjGdkN7M/vIVCpZ+Cf0L6muqOG4tLSl8o= github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= +github.com/moby/go-archive v0.2.0 h1:zg5QDUM2mi0JIM9fdQZWC7U8+2ZfixfTYoHL7rWUcP8= +github.com/moby/go-archive v0.2.0/go.mod h1:mNeivT14o8xU+5q1YnNrkQVpK+dnNe/K6fHqnTg4qPU= github.com/moby/patternmatcher v0.6.0 h1:GmP9lR19aU5GqSSFko+5pRqHi+Ohk1O69aFiKkVGiPk= github.com/moby/patternmatcher v0.6.0/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc= -github.com/moby/sys/sequential v0.5.0 h1:OPvI35Lzn9K04PBbCLW0g4LcFAJgHsvXsRyewg5lXtc= -github.com/moby/sys/sequential v0.5.0/go.mod h1:tH2cOOs5V9MlPiXcQzRC+eEyab644PWKGRYaaV5ZZlo= -github.com/moby/sys/user v0.1.0 h1:WmZ93f5Ux6het5iituh9x2zAG7NFY9Aqi49jjE1PaQg= -github.com/moby/sys/user v0.1.0/go.mod h1:fKJhFOnsCN6xZ5gSfbM6zaHGgDJMrqt9/reuj4T7MmU= +github.com/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw= +github.com/moby/sys/atomicwriter v0.1.0/go.mod h1:Ul8oqv2ZMNHOceF643P6FKPXeCmYtlQMvpizfsSoaWs= +github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU= +github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko= +github.com/moby/sys/user v0.4.0 h1:jhcMKit7SA80hivmFJcbB1vqmw//wU61Zdui2eQXuMs= +github.com/moby/sys/user v0.4.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs= +github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g= +github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28= github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0= github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y= -github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe h1:iruDEfMl2E6fbMZ9s0scYfZQ84/6SPL6zC8ACM2oIL0= -github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe/go.mod h1:wL8QJuTMNUDYhXwkmfOly8iTdp5TEcJFWZD2D7SIkUc= github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= @@ -129,8 +133,8 @@ github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOF github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= -github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug= -github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM= +github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= +github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= @@ -151,16 +155,12 @@ github.com/redis/go-redis/v9 v9.17.2 h1:P2EGsA4qVIM3Pp+aPocCJ7DguDHhqrXNhVcEp4Vi github.com/redis/go-redis/v9 v9.17.2/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= -github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= -github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/sagikazarmark/locafero v0.12.0 h1:/NQhBAkUb4+fH1jivKHWusDYFjMOOKU88eegjfxfHb4= github.com/sagikazarmark/locafero v0.12.0/go.mod h1:sZh36u/YSZ918v0Io+U9ogLYQJ9tLLBmM4eneO6WwsI= -github.com/shirou/gopsutil/v3 v3.23.12 h1:z90NtUkp3bMtmICZKpC4+WaknU1eXtp5vtbQ11DgpE4= -github.com/shirou/gopsutil/v3 v3.23.12/go.mod h1:1FrWgea594Jp7qmjHUUPlJDTPgcsb9mGnXDxavtikzM= -github.com/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFtM= -github.com/shoenig/go-m1cpu v0.1.6/go.mod h1:1JJMcUBvfNwpq05QDQVAnx3gUHr9IYF7GNg9SUEw2VQ= -github.com/shoenig/test v0.6.4 h1:kVTaSd7WLz5WZ2IaoM0RSzRsUD+m8wRR+5qvntpn4LU= -github.com/shoenig/test v0.6.4/go.mod h1:byHiCGXqrVaflBLAMq/srcZIHynQPQgeyvkvXnjqq0k= +github.com/shirou/gopsutil/v4 v4.25.6 h1:kLysI2JsKorfaFPcYmcJqbzROzsBWEOAtw6A7dIfqXs= +github.com/shirou/gopsutil/v4 v4.25.6/go.mod h1:PfybzyydfZcN+JMMjkF6Zb8Mq1A/VcogFFg7hj50W9c= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= @@ -172,25 +172,20 @@ github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3A github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= -github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= -github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= -github.com/testcontainers/testcontainers-go v0.34.0 h1:5fbgF0vIN5u+nD3IWabQwRybuB4GY8G2HHgCkbMzMHo= -github.com/testcontainers/testcontainers-go v0.34.0/go.mod h1:6P/kMkQe8yqPHfPWNulFGdFHTD8HB2vLq/231xY2iPQ= -github.com/testcontainers/testcontainers-go/modules/mongodb v0.34.0 h1:o3bgcECyBFfMwqexCH/6vIJ8XzbCffCP/Euesu33rgY= -github.com/testcontainers/testcontainers-go/modules/mongodb v0.34.0/go.mod h1:ljLR42dN7k40CX0dp30R8BRIB3OOdvr7rBANEpfmMs4= -github.com/testcontainers/testcontainers-go/modules/postgres v0.34.0 h1:c51aBXT3v2HEBVarmaBnsKzvgZjC5amn0qsj8Naqi50= -github.com/testcontainers/testcontainers-go/modules/postgres v0.34.0/go.mod h1:EWP75ogLQU4M4L8U+20mFipjV4WIR9WtlMXSB6/wiuc= +github.com/testcontainers/testcontainers-go v0.40.0 h1:pSdJYLOVgLE8YdUY2FHQ1Fxu+aMnb6JfVz1mxk7OeMU= +github.com/testcontainers/testcontainers-go v0.40.0/go.mod h1:FSXV5KQtX2HAMlm7U3APNyLkkap35zNLxukw9oBi/MY= +github.com/testcontainers/testcontainers-go/modules/mongodb v0.40.0 h1:z/1qHeliTLDKNaJ7uOHOx1FjwghbcbYfga4dTFkF0hU= +github.com/testcontainers/testcontainers-go/modules/mongodb v0.40.0/go.mod h1:GaunAWwMXLtsMKG3xn2HYIBDbKddGArfcGsF2Aog81E= +github.com/testcontainers/testcontainers-go/modules/postgres v0.40.0 h1:s2bIayFXlbDFexo96y+htn7FzuhpXLYJNnIuglNKqOk= +github.com/testcontainers/testcontainers-go/modules/postgres v0.40.0/go.mod h1:h+u/2KoREGTnTl9UwrQ/g+XhasAT8E6dClclAADeXoQ= github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU= github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI= github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk= @@ -209,29 +204,27 @@ github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZ github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM= github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI= -github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= -github.com/yusufpapurcu/wmi v1.2.3 h1:E1ctvB7uKFMOJw3fdOW32DwGE9I7t++CRUEMKvFoFiw= -github.com/yusufpapurcu/wmi v1.2.3/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= -go.mongodb.org/mongo-driver v1.13.1 h1:YIc7HTYsKndGK4RFzJ3covLz1byri52x0IoMB0Pt/vk= -go.mongodb.org/mongo-driver v1.13.1/go.mod h1:wcDf1JBCXy2mOW0bWHwO/IOYqdca1MPCwDtFu/Z9+eo= +github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= +github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= go.mongodb.org/mongo-driver/v2 v2.4.1 h1:hGDMngUao03OVQ6sgV5csk+RWOIkF+CuLsTPobNMGNI= go.mongodb.org/mongo-driver/v2 v2.4.1/go.mod h1:jHeEDJHJq7tm6ZF45Issun9dbogjfnPySb1vXA7EeAI= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 h1:jq9TW8u3so/bN+JPT166wjOI6/vQPF6Xe7nMNIltagk= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0/go.mod h1:p8pYQP+m5XfbZm9fxtSKAbM6oIllS7s2AfxrChvc7iw= -go.opentelemetry.io/otel v1.24.0 h1:0LAOdjNmQeSTzGBzduGe/rU4tZhMwL5rWgtp9Ku5Jfo= -go.opentelemetry.io/otel v1.24.0/go.mod h1:W7b9Ozg4nkF5tWI5zsXkaKKDjdVjpD4oAt9Qi/MArHo= +go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48= +go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.19.0 h1:Mne5On7VWdx7omSrSSZvM4Kw7cS7NQkOOmLcgscI51U= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.19.0/go.mod h1:IPtUMKL4O3tH5y+iXVyAXqpAwMuzC1IrxVS81rummfE= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0 h1:IeMeyr1aBvBiPVYihXIaeIZba6b8E1bYp7lbdxK8CQg= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0/go.mod h1:oVdCUtjq9MK9BlS7TtucsQwUcXcymNiEDjgDD2jMtZU= -go.opentelemetry.io/otel/metric v1.24.0 h1:6EhoGWWK28x1fbpA4tYTOWBkPefTDQnb8WSGXlc88kI= -go.opentelemetry.io/otel/metric v1.24.0/go.mod h1:VYhLe1rFfxuTXLgj4CBiyz+9WYBA8pNGJgDcSFRKBco= -go.opentelemetry.io/otel/sdk v1.19.0 h1:6USY6zH+L8uMH8L3t1enZPR3WFEmSTADlqldyHtJi3o= -go.opentelemetry.io/otel/sdk v1.19.0/go.mod h1:NedEbbS4w3C6zElbLdPJKOpJQOrGUJ+GfzpjUvI0v1A= -go.opentelemetry.io/otel/trace v1.24.0 h1:CsKnnL4dUAr/0llH9FKuc698G04IrpWV0MQA/Y1YELI= -go.opentelemetry.io/otel/trace v1.24.0/go.mod h1:HPc3Xr/cOApsBI154IU0OI0HJexz+aw5uPdbs3UCjNU= +go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0= +go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs= +go.opentelemetry.io/otel/sdk v1.21.0 h1:FTt8qirL1EysG6sTQRZ5TokkU8d0ugCj8htOgThZXQ8= +go.opentelemetry.io/otel/sdk v1.21.0/go.mod h1:Nna6Yv7PWTdgJHVRD9hIYywQBRx7pbox6nwBnZIxl/E= +go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI= +go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA= go.opentelemetry.io/proto/otlp v1.0.0 h1:T0TX0tmXU8a3CbNXzEKGeU5mIVOdf0oykP+u2lIVU/I= go.opentelemetry.io/proto/otlp v1.0.0/go.mod h1:Sy6pihPLfYHkr3NkUbEhGHFhINUSI/v80hjKIs5JXpM= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= @@ -241,36 +234,25 @@ go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU= golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0= golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY= golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70= -golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk= golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc= -golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -281,7 +263,6 @@ golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= @@ -298,22 +279,18 @@ golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= -golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ= golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -google.golang.org/genproto v0.0.0-20230920204549-e6e6cdab5c13 h1:vlzZttNJGVqTsRFU9AmdnrcO1Znh8Ew9kCD//yjigk0= -google.golang.org/genproto/googleapis/api v0.0.0-20240318140521-94a12d6c2237 h1:RFiFrvy37/mpSpdySBDrUdipW/dHwsRwh3J3+A9VgT4= -google.golang.org/genproto/googleapis/api v0.0.0-20240318140521-94a12d6c2237/go.mod h1:Z5Iiy3jtmioajWHDGFk7CeugTyHtPvMHA4UTmUkyalE= -google.golang.org/genproto/googleapis/rpc v0.0.0-20240318140521-94a12d6c2237 h1:NnYq6UN9ReLM9/Y01KWNOWyI5xQ9kbIms5GGJVwS/Yc= -google.golang.org/genproto/googleapis/rpc v0.0.0-20240318140521-94a12d6c2237/go.mod h1:WtryC6hu0hhx87FDGxWCDptyssuo68sk10vYjF+T9fY= -google.golang.org/grpc v1.64.1 h1:LKtvyfbX3UGVPFcGqJ9ItpVWW6oN/2XqTxfAnwRRXiA= -google.golang.org/grpc v1.64.1/go.mod h1:hiQF4LFZelK2WKaP6W0L92zGHtiQdZxk8CrSdvyjeP0= +google.golang.org/genproto v0.0.0-20230526203410-71b5a4ffd15e h1:Ao9GzfUMPH3zjVfzXG5rlWlk+Q8MXWKwWpwVQE1MXfw= +google.golang.org/genproto/googleapis/api v0.0.0-20240814211410-ddb44dafa142 h1:wKguEg1hsxI2/L3hUYrpo1RVi48K+uTyzKqprwLXsb8= +google.golang.org/genproto/googleapis/api v0.0.0-20240814211410-ddb44dafa142/go.mod h1:d6be+8HhtEtucleCbxpPW9PA9XwISACu8nvpPqF0BVo= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1 h1:pPJltXNxVzT4pK9yD8vR9X75DaWYYmLGMsEvBfFQZzQ= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1/go.mod h1:UqMtugtsSgubUsoxbuAoiCXvqvErP7Gf0so0mK9tHxU= +google.golang.org/grpc v1.67.0 h1:IdH9y6PF5MPSdAntIcpjQ+tXO41pcQsfZV2RxtQgVcw= +google.golang.org/grpc v1.67.0/go.mod h1:1gLDyUQU7CTLJI90u3nXZ9ekeghjeM7pTDZlqFNg2AA= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= @@ -322,8 +299,8 @@ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EV gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -gotest.tools/v3 v3.5.1 h1:EENdUnS3pdur5nybKYIh2Vfgc8IUNBjxDPSjtiJcOzU= -gotest.tools/v3 v3.5.1/go.mod h1:isy3WKz7GK6uNw/sbHzfKBLvlvXwUyV06n6brMxxopU= +gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= +gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis= modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= modernc.org/ccgo/v4 v4.30.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc= From 83cdceb8b2b2a8e9fa68ed1d2c13e746f65eafc1 Mon Sep 17 00:00:00 2001 From: "Jakub A. W" Date: Mon, 26 Jan 2026 15:50:30 +0100 Subject: [PATCH 6/8] sec: restrict workflow token permissions to read-only Add workflow-level permissions block to limit GITHUB_TOKEN scope to contents:read, following the principle of least privilege. Co-Authored-By: Claude Opus 4.5 --- .github/workflows/test.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 3b7e1129..86b32ccb 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -14,6 +14,10 @@ concurrency: env: GO_VERSION: "1.24" +# Restrict token permissions to minimum required +permissions: + contents: read + jobs: lint: name: lint From f4b78d9bad8c30a0aa707d0f2194a0962535e956 Mon Sep 17 00:00:00 2001 From: "Jakub A. W" Date: Mon, 26 Jan 2026 17:21:38 +0100 Subject: [PATCH 7/8] tests: added tests for streaming --- tests/integration/auditlog_test.go | 127 +++++++++++++++++++++++ tests/integration/helpers_test.go | 87 ++++++++++++++++ tests/integration/setup_test.go | 161 ++++++++++++++++++++++++++--- tests/integration/usage_test.go | 161 +++++++++++++++++++++++++++++ 4 files changed, 524 insertions(+), 12 deletions(-) diff --git a/tests/integration/auditlog_test.go b/tests/integration/auditlog_test.go index 44d6dddc..d63a44a0 100644 --- a/tests/integration/auditlog_test.go +++ b/tests/integration/auditlog_test.go @@ -3,6 +3,7 @@ package integration import ( + "io" "testing" "github.com/google/uuid" @@ -273,3 +274,129 @@ func TestAuditLog_DifferentModels_PostgreSQL(t *testing.T) { assert.Equal(t, model, entries[0].Model, "model mismatch for request %s", reqID) } } + +func TestAuditLog_StreamingChatCompletion_PostgreSQL(t *testing.T) { + fixture := SetupTestServer(t, TestServerConfig{ + DBType: "postgresql", + AuditLogEnabled: true, + UsageEnabled: false, + LogBodies: true, + LogHeaders: true, + OnlyModelInteractions: false, + }) + + requestID := uuid.New().String() + + // Make streaming request + payload := newStreamingChatRequest("gpt-4", "Hello, world!") + resp := sendChatRequestWithHeaders(t, fixture.ServerURL, payload, map[string]string{ + "X-Request-ID": requestID, + }) + require.Equal(t, 200, resp.StatusCode) + assert.Equal(t, "text/event-stream", resp.Header.Get("Content-Type")) + + // Read and close the stream + _, _ = io.ReadAll(resp.Body) + closeBody(resp) + + // CRITICAL: Flush before querying DB + fixture.FlushAndClose(t) + + // Query and assert DB state + entries := dbassert.QueryAuditLogsByRequestID(t, fixture.PgPool, requestID) + require.Len(t, entries, 1, "expected exactly one audit log entry for streaming request") + + entry := entries[0] + + // Assert specific values + dbassert.AssertAuditLogMatches(t, dbassert.ExpectedAuditLog{ + Model: "gpt-4", + StatusCode: 200, + Method: "POST", + Path: "/v1/chat/completions", + RequestID: requestID, + }, entry) + + // Assert duration is positive + dbassert.AssertAuditLogDurationPositive(t, entry) +} + +func TestAuditLog_StreamingChatCompletion_MongoDB(t *testing.T) { + fixture := SetupTestServer(t, TestServerConfig{ + DBType: "mongodb", + AuditLogEnabled: true, + UsageEnabled: false, + LogBodies: true, + LogHeaders: true, + OnlyModelInteractions: false, + }) + + requestID := uuid.New().String() + + // Make streaming request + payload := newStreamingChatRequest("gpt-4", "Hello, world!") + resp := sendChatRequestWithHeaders(t, fixture.ServerURL, payload, map[string]string{ + "X-Request-ID": requestID, + }) + require.Equal(t, 200, resp.StatusCode) + assert.Equal(t, "text/event-stream", resp.Header.Get("Content-Type")) + + // Read and close the stream + _, _ = io.ReadAll(resp.Body) + closeBody(resp) + + // CRITICAL: Flush before querying DB + fixture.FlushAndClose(t) + + // Query and assert DB state + entries := dbassert.QueryAuditLogsByRequestIDMongo(t, fixture.MongoDb, requestID) + require.Len(t, entries, 1, "expected exactly one audit log entry for streaming request") + + entry := entries[0] + + // Assert specific values + dbassert.AssertAuditLogMatches(t, dbassert.ExpectedAuditLog{ + Model: "gpt-4", + StatusCode: 200, + Method: "POST", + Path: "/v1/chat/completions", + RequestID: requestID, + }, entry) +} + +func TestAuditLog_StreamingResponses_PostgreSQL(t *testing.T) { + fixture := SetupTestServer(t, TestServerConfig{ + DBType: "postgresql", + AuditLogEnabled: true, + UsageEnabled: false, + LogBodies: true, + OnlyModelInteractions: false, + }) + + requestID := uuid.New().String() + + // Make streaming responses request + payload := newStreamingResponsesRequest("gpt-4", "Hello!") + resp := sendResponsesRequestWithHeaders(t, fixture.ServerURL, payload, map[string]string{ + "X-Request-ID": requestID, + }) + require.Equal(t, 200, resp.StatusCode) + assert.Equal(t, "text/event-stream", resp.Header.Get("Content-Type")) + + // Read and close the stream + _, _ = io.ReadAll(resp.Body) + closeBody(resp) + + fixture.FlushAndClose(t) + + entries := dbassert.QueryAuditLogsByRequestID(t, fixture.PgPool, requestID) + require.Len(t, entries, 1) + + dbassert.AssertAuditLogMatches(t, dbassert.ExpectedAuditLog{ + Model: "gpt-4", + StatusCode: 200, + Method: "POST", + Path: "/v1/responses", + RequestID: requestID, + }, entries[0]) +} diff --git a/tests/integration/helpers_test.go b/tests/integration/helpers_test.go index a4ebd2d6..e6e02a0e 100644 --- a/tests/integration/helpers_test.go +++ b/tests/integration/helpers_test.go @@ -159,3 +159,90 @@ func forwardResponsesRequest(ctx context.Context, client *http.Client, baseURL, return &responsesResp, nil } + +// forwardStreamingChatRequest forwards a streaming chat request to the upstream server. +func forwardStreamingChatRequest(ctx context.Context, client *http.Client, baseURL, apiKey string, req *core.ChatRequest) (io.ReadCloser, error) { + // Ensure stream is set + req.Stream = true + + body, err := json.Marshal(req) + if err != nil { + return nil, fmt.Errorf("failed to marshal request: %w", err) + } + + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, baseURL+chatCompletionsPath, bytes.NewReader(body)) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("Authorization", "Bearer "+apiKey) + + resp, err := client.Do(httpReq) + if err != nil { + return nil, fmt.Errorf("failed to send request: %w", err) + } + + if resp.StatusCode != http.StatusOK { + respBody, _ := io.ReadAll(resp.Body) + resp.Body.Close() + return nil, fmt.Errorf("unexpected status %d: %s", resp.StatusCode, string(respBody)) + } + + return resp.Body, nil +} + +// forwardStreamingResponsesRequest forwards a streaming responses request to the upstream server. +func forwardStreamingResponsesRequest(ctx context.Context, client *http.Client, baseURL, apiKey string, req *core.ResponsesRequest) (io.ReadCloser, error) { + // Ensure stream is set + req.Stream = true + + body, err := json.Marshal(req) + if err != nil { + return nil, fmt.Errorf("failed to marshal request: %w", err) + } + + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, baseURL+responsesPath, bytes.NewReader(body)) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("Authorization", "Bearer "+apiKey) + + resp, err := client.Do(httpReq) + if err != nil { + return nil, fmt.Errorf("failed to send request: %w", err) + } + + if resp.StatusCode != http.StatusOK { + respBody, _ := io.ReadAll(resp.Body) + resp.Body.Close() + return nil, fmt.Errorf("unexpected status %d: %s", resp.StatusCode, string(respBody)) + } + + return resp.Body, nil +} + +// newStreamingChatRequest creates a streaming chat request for testing. +func newStreamingChatRequest(model, content string) core.ChatRequest { + return core.ChatRequest{ + Model: model, + Stream: true, + Messages: []core.Message{ + { + Role: "user", + Content: content, + }, + }, + } +} + +// newStreamingResponsesRequest creates a streaming responses request for testing. +func newStreamingResponsesRequest(model, content string) core.ResponsesRequest { + return core.ResponsesRequest{ + Model: model, + Stream: true, + Input: content, + } +} diff --git a/tests/integration/setup_test.go b/tests/integration/setup_test.go index 39a56bbe..9c2640cf 100644 --- a/tests/integration/setup_test.go +++ b/tests/integration/setup_test.go @@ -3,7 +3,9 @@ package integration import ( + "bytes" "context" + "encoding/json" "fmt" "io" "net" @@ -263,11 +265,15 @@ type MockLLMServer struct { // NewMockLLMServer creates a new mock LLM server. func NewMockLLMServer() *MockLLMServer { handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Read body for stream detection + body, _ := io.ReadAll(r.Body) + r.Body = io.NopCloser(bytes.NewReader(body)) + switch r.URL.Path { case "/v1/chat/completions": - handleChatCompletion(w, r) + handleChatCompletion(w, r, body) case "/v1/responses": - handleResponses(w, r) + handleResponses(w, r, body) case "/v1/models": handleModels(w) default: @@ -289,7 +295,17 @@ func (m *MockLLMServer) Close() { m.server.Close() } -func handleChatCompletion(w http.ResponseWriter, r *http.Request) { +func handleChatCompletion(w http.ResponseWriter, _ *http.Request, body []byte) { + // Check if streaming is requested + var req struct { + Stream bool `json:"stream"` + Model string `json:"model"` + } + if err := json.Unmarshal(body, &req); err == nil && req.Stream { + handleChatCompletionStream(w, req.Model) + return + } + w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) @@ -315,7 +331,71 @@ func handleChatCompletion(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte(response)) } -func handleResponses(w http.ResponseWriter, r *http.Request) { +func handleChatCompletionStream(w http.ResponseWriter, model string) { + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Connection", "keep-alive") + w.WriteHeader(http.StatusOK) + + flusher, ok := w.(http.Flusher) + if !ok { + return + } + + if model == "" { + model = "gpt-4" + } + + // Send content chunks + chunks := []string{"Hello", "!", " How", " can", " I", " help", " you", " today", "?"} + for i, chunk := range chunks { + delta := map[string]interface{}{ + "id": "chatcmpl-test-stream", + "object": "chat.completion.chunk", + "model": model, + "created": 1700000000, + "choices": []map[string]interface{}{ + { + "index": 0, + "delta": map[string]interface{}{ + "content": chunk, + }, + "finish_reason": nil, + }, + }, + } + + // Last chunk has finish_reason and usage + if i == len(chunks)-1 { + delta["choices"].([]map[string]interface{})[0]["finish_reason"] = "stop" + delta["usage"] = map[string]interface{}{ + "prompt_tokens": 10, + "completion_tokens": 8, + "total_tokens": 18, + } + } + + data, _ := json.Marshal(delta) + _, _ = fmt.Fprintf(w, "data: %s\n\n", data) + flusher.Flush() + } + + // Send done marker + _, _ = fmt.Fprintf(w, "data: [DONE]\n\n") + flusher.Flush() +} + +func handleResponses(w http.ResponseWriter, _ *http.Request, body []byte) { + // Check if streaming is requested + var req struct { + Stream bool `json:"stream"` + Model string `json:"model"` + } + if err := json.Unmarshal(body, &req); err == nil && req.Stream { + handleResponsesStream(w, req.Model) + return + } + w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) @@ -342,6 +422,69 @@ func handleResponses(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte(response)) } +func handleResponsesStream(w http.ResponseWriter, model string) { + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Connection", "keep-alive") + w.WriteHeader(http.StatusOK) + + flusher, ok := w.(http.Flusher) + if !ok { + return + } + + if model == "" { + model = "gpt-4" + } + + // Send response.created event + createdEvent := map[string]interface{}{ + "type": "response.created", + "response": map[string]interface{}{ + "id": "resp-test-stream", + "object": "response", + "created_at": 1700000000, + "model": model, + "status": "in_progress", + }, + } + data, _ := json.Marshal(createdEvent) + _, _ = fmt.Fprintf(w, "event: response.created\ndata: %s\n\n", data) + flusher.Flush() + + // Send content delta events + chunks := []string{"Hello", "!", " How", " can", " I", " help", " you", "?"} + for _, chunk := range chunks { + deltaEvent := map[string]interface{}{ + "type": "response.output_text.delta", + "delta": chunk, + } + data, _ = json.Marshal(deltaEvent) + _, _ = fmt.Fprintf(w, "event: response.output_text.delta\ndata: %s\n\n", data) + flusher.Flush() + } + + // Send response.done event with usage + doneEvent := map[string]interface{}{ + "type": "response.done", + "response": map[string]interface{}{ + "id": "resp-test-stream", + "object": "response", + "created_at": 1700000000, + "model": model, + "status": "completed", + "usage": map[string]interface{}{ + "input_tokens": 10, + "output_tokens": 8, + "total_tokens": 18, + }, + }, + } + data, _ = json.Marshal(doneEvent) + _, _ = fmt.Fprintf(w, "event: response.done\ndata: %s\n\n", data) + flusher.Flush() +} + func handleModels(w http.ResponseWriter) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) @@ -380,13 +523,7 @@ func (p *TestProvider) ChatCompletion(ctx context.Context, req *core.ChatRequest // StreamChatCompletion forwards the streaming request to the mock server. func (p *TestProvider) StreamChatCompletion(ctx context.Context, req *core.ChatRequest) (io.ReadCloser, error) { - // For simplicity, return non-streaming response wrapped in ReadCloser - resp, err := p.ChatCompletion(ctx, req) - if err != nil { - return nil, err - } - _ = resp // Would need to convert to SSE format for real streaming tests - return nil, fmt.Errorf("streaming not implemented in test provider") + return forwardStreamingChatRequest(ctx, p.httpClient, p.baseURL, p.apiKey, req) } // ListModels returns a mock list of models. @@ -408,5 +545,5 @@ func (p *TestProvider) Responses(ctx context.Context, req *core.ResponsesRequest // StreamResponses forwards the streaming responses API request to the mock server. func (p *TestProvider) StreamResponses(ctx context.Context, req *core.ResponsesRequest) (io.ReadCloser, error) { - return nil, fmt.Errorf("streaming not implemented in test provider") + return forwardStreamingResponsesRequest(ctx, p.httpClient, p.baseURL, p.apiKey, req) } diff --git a/tests/integration/usage_test.go b/tests/integration/usage_test.go index 6b0ccdb7..23f6f73b 100644 --- a/tests/integration/usage_test.go +++ b/tests/integration/usage_test.go @@ -3,6 +3,7 @@ package integration import ( + "io" "testing" "github.com/google/uuid" @@ -252,3 +253,163 @@ func TestUsage_BothAuditAndUsage_PostgreSQL(t *testing.T) { assert.Equal(t, requestID, auditEntries[0].RequestID) assert.Equal(t, requestID, usageEntries[0].RequestID) } + +func TestUsage_StreamingChatCompletion_PostgreSQL(t *testing.T) { + fixture := SetupTestServer(t, TestServerConfig{ + DBType: "postgresql", + AuditLogEnabled: false, + UsageEnabled: true, + OnlyModelInteractions: false, + }) + + requestID := uuid.New().String() + + // Make streaming request + payload := newStreamingChatRequest("gpt-4", "Hello, world!") + resp := sendChatRequestWithHeaders(t, fixture.ServerURL, payload, map[string]string{ + "X-Request-ID": requestID, + }) + require.Equal(t, 200, resp.StatusCode) + assert.Equal(t, "text/event-stream", resp.Header.Get("Content-Type")) + + // Read and close the stream to ensure it completes + _, _ = io.ReadAll(resp.Body) + closeBody(resp) + + // CRITICAL: Flush before querying DB + fixture.FlushAndClose(t) + + // Query and assert DB state + entries := dbassert.QueryUsageByRequestID(t, fixture.PgPool, requestID) + require.Len(t, entries, 1, "expected exactly one usage entry for streaming request") + + entry := entries[0] + + // Assert field completeness + dbassert.AssertUsageFieldCompleteness(t, entry) + + // Assert specific values + dbassert.AssertUsageMatches(t, dbassert.ExpectedUsage{ + Model: "gpt-4", + Provider: "test", + Endpoint: "/v1/chat/completions", + RequestID: requestID, + }, entry) + + // Assert token counts are populated from streaming response + dbassert.AssertUsageHasTokens(t, entry) + + // Verify token values from mock streaming response + assert.Equal(t, 10, entry.InputTokens, "input tokens mismatch") + assert.Equal(t, 8, entry.OutputTokens, "output tokens mismatch") + assert.Equal(t, 18, entry.TotalTokens, "total tokens mismatch") +} + +func TestUsage_StreamingChatCompletion_MongoDB(t *testing.T) { + fixture := SetupTestServer(t, TestServerConfig{ + DBType: "mongodb", + AuditLogEnabled: false, + UsageEnabled: true, + OnlyModelInteractions: false, + }) + + requestID := uuid.New().String() + + // Make streaming request + payload := newStreamingChatRequest("gpt-4", "Hello, world!") + resp := sendChatRequestWithHeaders(t, fixture.ServerURL, payload, map[string]string{ + "X-Request-ID": requestID, + }) + require.Equal(t, 200, resp.StatusCode) + assert.Equal(t, "text/event-stream", resp.Header.Get("Content-Type")) + + // Read and close the stream + _, _ = io.ReadAll(resp.Body) + closeBody(resp) + + fixture.FlushAndClose(t) + + entries := dbassert.QueryUsageByRequestIDMongo(t, fixture.MongoDb, requestID) + require.Len(t, entries, 1, "expected exactly one usage entry for streaming request") + + entry := entries[0] + + dbassert.AssertUsageFieldCompleteness(t, entry) + dbassert.AssertUsageMatches(t, dbassert.ExpectedUsage{ + Model: "gpt-4", + Provider: "test", + Endpoint: "/v1/chat/completions", + RequestID: requestID, + }, entry) +} + +func TestUsage_StreamingResponses_PostgreSQL(t *testing.T) { + fixture := SetupTestServer(t, TestServerConfig{ + DBType: "postgresql", + AuditLogEnabled: false, + UsageEnabled: true, + OnlyModelInteractions: false, + }) + + requestID := uuid.New().String() + + // Make streaming responses request + payload := newStreamingResponsesRequest("gpt-4", "Hello!") + resp := sendResponsesRequestWithHeaders(t, fixture.ServerURL, payload, map[string]string{ + "X-Request-ID": requestID, + }) + require.Equal(t, 200, resp.StatusCode) + assert.Equal(t, "text/event-stream", resp.Header.Get("Content-Type")) + + // Read and close the stream + _, _ = io.ReadAll(resp.Body) + closeBody(resp) + + fixture.FlushAndClose(t) + + entries := dbassert.QueryUsageByRequestID(t, fixture.PgPool, requestID) + require.Len(t, entries, 1) + + dbassert.AssertUsageMatches(t, dbassert.ExpectedUsage{ + Model: "gpt-4", + Provider: "test", + Endpoint: "/v1/responses", + RequestID: requestID, + }, entries[0]) +} + +func TestUsage_StreamingBothAuditAndUsage_PostgreSQL(t *testing.T) { + fixture := SetupTestServer(t, TestServerConfig{ + DBType: "postgresql", + AuditLogEnabled: true, + UsageEnabled: true, + LogBodies: true, + OnlyModelInteractions: false, + }) + + requestID := uuid.New().String() + + // Make streaming request + payload := newStreamingChatRequest("gpt-4", "Hello!") + resp := sendChatRequestWithHeaders(t, fixture.ServerURL, payload, map[string]string{ + "X-Request-ID": requestID, + }) + require.Equal(t, 200, resp.StatusCode) + + // Read and close the stream + _, _ = io.ReadAll(resp.Body) + closeBody(resp) + + fixture.FlushAndClose(t) + + // Both tables should have entries for streaming request + auditEntries := dbassert.QueryAuditLogsByRequestID(t, fixture.PgPool, requestID) + require.Len(t, auditEntries, 1, "expected audit log entry for streaming") + + usageEntries := dbassert.QueryUsageByRequestID(t, fixture.PgPool, requestID) + require.Len(t, usageEntries, 1, "expected usage entry for streaming") + + // They should share the same request ID + assert.Equal(t, requestID, auditEntries[0].RequestID) + assert.Equal(t, requestID, usageEntries[0].RequestID) +} From da8d761b2a9bd53883f30a80e463ff8410680db8 Mon Sep 17 00:00:00 2001 From: "Jakub A. W" Date: Mon, 26 Jan 2026 17:51:16 +0100 Subject: [PATCH 8/8] fix: call cancelFunc explicitly before os.Exit in TestMain Deferred functions don't run when os.Exit is called. Move cancelFunc() from defer to explicit calls before both exit paths to ensure the context is always cancelled. Co-Authored-By: Claude Opus 4.5 --- tests/integration/main_test.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/integration/main_test.go b/tests/integration/main_test.go index c3b21398..cff42dec 100644 --- a/tests/integration/main_test.go +++ b/tests/integration/main_test.go @@ -42,7 +42,6 @@ var ( // TestMain sets up and tears down the test containers. func TestMain(m *testing.M) { testCtx, cancelFunc = context.WithTimeout(context.Background(), 10*time.Minute) - defer cancelFunc() // Start containers in parallel errCh := make(chan error, 2) @@ -60,6 +59,7 @@ func TestMain(m *testing.M) { if err := <-errCh; err != nil { log.Printf("Container setup failed: %v", err) cleanup() + cancelFunc() os.Exit(1) } } @@ -71,6 +71,7 @@ func TestMain(m *testing.M) { // Cleanup cleanup() + cancelFunc() os.Exit(code) }