Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .claude/settings.local.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": []
Expand Down
22 changes: 21 additions & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ concurrency:
env:
GO_VERSION: "1.24"

# Restrict token permissions to minimum required
permissions:
contents: read

jobs:
lint:
name: lint
Expand Down Expand Up @@ -53,10 +57,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]
Comment thread
SantiagoDePolonia marked this conversation as resolved.
steps:
- uses: actions/checkout@v5

Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
# Compiled binary
/gomodel
/recordapi
/bin

# Environment variables
Expand Down
27 changes: 24 additions & 3 deletions Makefile
Original file line number Diff line number Diff line change
@@ -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)
Expand Down Expand Up @@ -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:
Expand Down
275 changes: 275 additions & 0 deletions cmd/recordapi/main.go
Original file line number Diff line number Diff line change
@@ -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,
},
}
Comment thread
SantiagoDePolonia marked this conversation as resolved.

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)
}
Comment thread
SantiagoDePolonia marked this conversation as resolved.

// 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
}
Comment on lines +190 to +205

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Stream SSE responses directly to disk instead of ReadAll.

For streaming endpoints, buffering the entire response defeats the purpose and can spike memory. Pipe resp.Body to file when the endpoint is a stream.

♻️ Proposed refactor (stream to file)
-	// 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 {
+		if err := writeStreamOutput(*output, resp.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
 	}
+
+	// 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)
+	}
@@
-func writeStreamOutput(path string, data []byte) error {
-	// For streaming responses, save as-is (SSE format)
-	return writeOutput(path, data)
+func writeStreamOutput(path string, r io.Reader) error {
+	dir := filepath.Dir(path)
+	if err := os.MkdirAll(dir, 0755); err != nil {
+		return fmt.Errorf("failed to create directory: %w", err)
+	}
+	f, err := os.Create(path)
+	if err != nil {
+		return err
+	}
+	defer func() { _ = f.Close() }()
+	_, err = io.Copy(f, r)
+	return err
 }

Also applies to: 271-274

🤖 Prompt for AI Agents
In `@cmd/recordapi/main.go` around lines 190 - 205, The code currently calls
io.ReadAll(resp.Body) for all responses then checks if *endpoint has "_stream",
which buffers the entire stream into memory; change the streaming path to stream
directly from resp.Body to disk instead of ReadAll. Concretely, stop using
io.ReadAll for stream endpoints: for the "_stream" branch call a function that
accepts io.Reader (e.g., update writeStreamOutput to take an io.Reader and
filename) and stream with io.Copy(file, resp.Body), handling errors and closing
the file and resp.Body appropriately; leave non-stream responses to use
io.ReadAll as before. Also update the other occurrence that handles streaming
(the block around writeStreamOutput at lines ~271-274) to use the new
reader-based streaming approach.


// 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)
Comment thread
SantiagoDePolonia marked this conversation as resolved.
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)
}
Loading