Compare commits
4 Commits
d782204c68
...
59ded107a7
| Author | SHA1 | Date | |
|---|---|---|---|
| 59ded107a7 | |||
| f8653ebc26 | |||
| ccb8267813 | |||
| 1e0bb0be8c |
327
OBSERVABILITY.md
327
OBSERVABILITY.md
@@ -1,327 +0,0 @@
|
||||
# Observability Implementation
|
||||
|
||||
This document describes the observability features implemented in the LLM Gateway.
|
||||
|
||||
## Overview
|
||||
|
||||
The gateway now includes comprehensive observability with:
|
||||
- **Prometheus Metrics**: Track HTTP requests, provider calls, token usage, and conversation operations
|
||||
- **OpenTelemetry Tracing**: Distributed tracing with OTLP exporter support
|
||||
- **Enhanced Logging**: Trace context correlation for log aggregation
|
||||
|
||||
## Configuration
|
||||
|
||||
Add the following to your `config.yaml`:
|
||||
|
||||
```yaml
|
||||
observability:
|
||||
enabled: true # Master switch for all observability features
|
||||
|
||||
metrics:
|
||||
enabled: true
|
||||
path: "/metrics" # Prometheus metrics endpoint
|
||||
|
||||
tracing:
|
||||
enabled: true
|
||||
service_name: "llm-gateway"
|
||||
sampler:
|
||||
type: "probability" # "always", "never", or "probability"
|
||||
rate: 0.1 # 10% sampling rate
|
||||
exporter:
|
||||
type: "otlp" # "otlp" for production, "stdout" for development
|
||||
endpoint: "localhost:4317" # OTLP collector endpoint
|
||||
insecure: true # Use insecure connection (for development)
|
||||
# headers: # Optional authentication headers
|
||||
# authorization: "Bearer your-token"
|
||||
```
|
||||
|
||||
## Metrics
|
||||
|
||||
### HTTP Metrics
|
||||
- `http_requests_total` - Total HTTP requests (labels: method, path, status)
|
||||
- `http_request_duration_seconds` - Request latency histogram
|
||||
- `http_request_size_bytes` - Request body size histogram
|
||||
- `http_response_size_bytes` - Response body size histogram
|
||||
|
||||
### Provider Metrics
|
||||
- `provider_requests_total` - Provider API calls (labels: provider, model, operation, status)
|
||||
- `provider_request_duration_seconds` - Provider latency histogram
|
||||
- `provider_tokens_total` - Token usage (labels: provider, model, type=input/output)
|
||||
- `provider_stream_ttfb_seconds` - Time to first byte for streaming
|
||||
- `provider_stream_chunks_total` - Stream chunk count
|
||||
- `provider_stream_duration_seconds` - Total stream duration
|
||||
|
||||
### Conversation Store Metrics
|
||||
- `conversation_operations_total` - Store operations (labels: operation, backend, status)
|
||||
- `conversation_operation_duration_seconds` - Store operation latency
|
||||
- `conversation_active_count` - Current number of conversations (gauge)
|
||||
|
||||
### Example Queries
|
||||
|
||||
```promql
|
||||
# Request rate
|
||||
rate(http_requests_total[5m])
|
||||
|
||||
# P95 latency
|
||||
histogram_quantile(0.95, rate(http_request_duration_seconds_bucket[5m]))
|
||||
|
||||
# Error rate
|
||||
rate(http_requests_total{status=~"5.."}[5m])
|
||||
|
||||
# Tokens per minute by model
|
||||
rate(provider_tokens_total[1m]) * 60
|
||||
|
||||
# Provider latency by model
|
||||
histogram_quantile(0.95, rate(provider_request_duration_seconds_bucket[5m])) by (provider, model)
|
||||
```
|
||||
|
||||
## Tracing
|
||||
|
||||
### Trace Structure
|
||||
|
||||
Each request creates a trace with the following span hierarchy:
|
||||
```
|
||||
HTTP GET /v1/responses
|
||||
├── provider.generate or provider.generate_stream
|
||||
├── conversation.get (if using previous_response_id)
|
||||
└── conversation.create (to store result)
|
||||
```
|
||||
|
||||
### Span Attributes
|
||||
|
||||
HTTP spans include:
|
||||
- `http.method`, `http.route`, `http.status_code`
|
||||
- `http.request_id` - Request ID for correlation
|
||||
- `trace_id`, `span_id` - For log correlation
|
||||
|
||||
Provider spans include:
|
||||
- `provider.name`, `provider.model`
|
||||
- `provider.input_tokens`, `provider.output_tokens`
|
||||
- `provider.chunk_count`, `provider.ttfb_seconds` (for streaming)
|
||||
|
||||
Conversation spans include:
|
||||
- `conversation.id`, `conversation.backend`
|
||||
- `conversation.message_count`, `conversation.model`
|
||||
|
||||
### Log Correlation
|
||||
|
||||
Logs now include `trace_id` and `span_id` fields when tracing is enabled, allowing you to:
|
||||
1. Find all logs for a specific trace
|
||||
2. Jump from a log entry to the corresponding trace in Jaeger/Tempo
|
||||
|
||||
Example log entry:
|
||||
```json
|
||||
{
|
||||
"time": "2026-03-03T06:36:44Z",
|
||||
"level": "INFO",
|
||||
"msg": "response generated",
|
||||
"request_id": "74722802-6be1-4e14-8e73-d86823fed3e3",
|
||||
"trace_id": "5d8a7c3f2e1b9a8c7d6e5f4a3b2c1d0e",
|
||||
"span_id": "1a2b3c4d5e6f7a8b",
|
||||
"provider": "openai",
|
||||
"model": "gpt-4o-mini",
|
||||
"input_tokens": 23,
|
||||
"output_tokens": 156
|
||||
}
|
||||
```
|
||||
|
||||
## Testing Observability
|
||||
|
||||
### 1. Test Metrics Endpoint
|
||||
|
||||
```bash
|
||||
# Start the gateway with observability enabled
|
||||
./bin/gateway -config config.yaml
|
||||
|
||||
# Query metrics endpoint
|
||||
curl http://localhost:8080/metrics
|
||||
```
|
||||
|
||||
Expected output includes:
|
||||
```
|
||||
# HELP http_requests_total Total number of HTTP requests
|
||||
# TYPE http_requests_total counter
|
||||
http_requests_total{method="GET",path="/metrics",status="200"} 1
|
||||
|
||||
# HELP conversation_active_count Number of active conversations
|
||||
# TYPE conversation_active_count gauge
|
||||
conversation_active_count{backend="memory"} 0
|
||||
```
|
||||
|
||||
### 2. Test Tracing with Stdout Exporter
|
||||
|
||||
Set up config with stdout exporter for quick testing:
|
||||
|
||||
```yaml
|
||||
observability:
|
||||
enabled: true
|
||||
tracing:
|
||||
enabled: true
|
||||
sampler:
|
||||
type: "always"
|
||||
exporter:
|
||||
type: "stdout"
|
||||
```
|
||||
|
||||
Make a request and check the logs for JSON-formatted spans.
|
||||
|
||||
### 3. Test Tracing with Jaeger
|
||||
|
||||
Run Jaeger with OTLP support:
|
||||
|
||||
```bash
|
||||
docker run -d --name jaeger \
|
||||
-e COLLECTOR_OTLP_ENABLED=true \
|
||||
-p 4317:4317 \
|
||||
-p 16686:16686 \
|
||||
jaegertracing/all-in-one:latest
|
||||
```
|
||||
|
||||
Update config:
|
||||
```yaml
|
||||
observability:
|
||||
enabled: true
|
||||
tracing:
|
||||
enabled: true
|
||||
sampler:
|
||||
type: "probability"
|
||||
rate: 1.0 # 100% for testing
|
||||
exporter:
|
||||
type: "otlp"
|
||||
endpoint: "localhost:4317"
|
||||
insecure: true
|
||||
```
|
||||
|
||||
Make requests and view traces at http://localhost:16686
|
||||
|
||||
### 4. End-to-End Test
|
||||
|
||||
```bash
|
||||
# Make a test request
|
||||
curl -X POST http://localhost:8080/v1/responses \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"model": "gpt-4o-mini",
|
||||
"input": "Hello, world!"
|
||||
}'
|
||||
|
||||
# Check metrics
|
||||
curl http://localhost:8080/metrics | grep -E "(http_requests|provider_)"
|
||||
|
||||
# Expected metrics updates:
|
||||
# - http_requests_total incremented
|
||||
# - provider_requests_total incremented
|
||||
# - provider_tokens_total incremented for input and output
|
||||
# - provider_request_duration_seconds updated
|
||||
```
|
||||
|
||||
### 5. Load Test
|
||||
|
||||
```bash
|
||||
# Install hey if needed
|
||||
go install github.com/rakyll/hey@latest
|
||||
|
||||
# Run load test
|
||||
hey -n 1000 -c 10 -m POST \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"model":"gpt-4o-mini","input":"test"}' \
|
||||
http://localhost:8080/v1/responses
|
||||
|
||||
# Check metrics for aggregated data
|
||||
curl http://localhost:8080/metrics | grep http_request_duration_seconds
|
||||
```
|
||||
|
||||
## Integration with Monitoring Stack
|
||||
|
||||
### Prometheus
|
||||
|
||||
Add to `prometheus.yml`:
|
||||
|
||||
```yaml
|
||||
scrape_configs:
|
||||
- job_name: 'llm-gateway'
|
||||
static_configs:
|
||||
- targets: ['localhost:8080']
|
||||
metrics_path: '/metrics'
|
||||
scrape_interval: 15s
|
||||
```
|
||||
|
||||
### Grafana
|
||||
|
||||
Import dashboards for:
|
||||
- HTTP request rates and latencies
|
||||
- Provider performance by model
|
||||
- Token usage and costs
|
||||
- Error rates and types
|
||||
|
||||
### Tempo/Jaeger
|
||||
|
||||
The gateway exports traces via OTLP protocol. Configure your trace backend to accept OTLP on port 4317 (gRPC).
|
||||
|
||||
## Architecture
|
||||
|
||||
### Middleware Chain
|
||||
|
||||
```
|
||||
Client Request
|
||||
↓
|
||||
loggingMiddleware (request ID, logging)
|
||||
↓
|
||||
tracingMiddleware (W3C Trace Context, spans)
|
||||
↓
|
||||
metricsMiddleware (Prometheus metrics)
|
||||
↓
|
||||
rateLimitMiddleware (rate limiting)
|
||||
↓
|
||||
authMiddleware (authentication)
|
||||
↓
|
||||
Application Routes
|
||||
```
|
||||
|
||||
### Instrumentation Pattern
|
||||
|
||||
- **Providers**: Wrapped with `InstrumentedProvider` that tracks calls, latency, and token usage
|
||||
- **Conversation Store**: Wrapped with `InstrumentedStore` that tracks operations and size
|
||||
- **HTTP Layer**: Middleware captures request/response metrics and creates trace spans
|
||||
|
||||
### W3C Trace Context
|
||||
|
||||
The gateway supports W3C Trace Context propagation:
|
||||
- Extracts `traceparent` header from incoming requests
|
||||
- Creates child spans for downstream operations
|
||||
- Propagates context through the entire request lifecycle
|
||||
|
||||
## Performance Impact
|
||||
|
||||
Observability features have minimal overhead:
|
||||
- Metrics: < 1% latency increase
|
||||
- Tracing (10% sampling): < 2% latency increase
|
||||
- Tracing (100% sampling): < 5% latency increase
|
||||
|
||||
Recommended configuration for production:
|
||||
- Metrics: Enabled
|
||||
- Tracing: Enabled with 10-20% sampling rate
|
||||
- Exporter: OTLP to dedicated collector
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Metrics endpoint returns 404
|
||||
- Check `observability.metrics.enabled` is `true`
|
||||
- Verify `observability.enabled` is `true`
|
||||
- Check `observability.metrics.path` configuration
|
||||
|
||||
### No traces appearing in Jaeger
|
||||
- Verify OTLP collector is running on configured endpoint
|
||||
- Check sampling rate (try `type: "always"` for testing)
|
||||
- Look for tracer initialization errors in logs
|
||||
- Verify `observability.tracing.enabled` is `true`
|
||||
|
||||
### High memory usage
|
||||
- Reduce trace sampling rate
|
||||
- Check for metric cardinality explosion (too many label combinations)
|
||||
- Consider using recording rules in Prometheus
|
||||
|
||||
### Missing trace IDs in logs
|
||||
- Ensure tracing is enabled
|
||||
- Check that requests are being sampled (sampling rate > 0)
|
||||
- Verify OpenTelemetry dependencies are correctly installed
|
||||
@@ -1,169 +0,0 @@
|
||||
# Security Improvements - March 2026
|
||||
|
||||
This document summarizes the security and reliability improvements made to the go-llm-gateway project.
|
||||
|
||||
## Issues Fixed
|
||||
|
||||
### 1. Request Size Limits (Issue #2) ✅
|
||||
|
||||
**Problem**: The server had no limits on request body size, making it vulnerable to DoS attacks via oversized payloads.
|
||||
|
||||
**Solution**: Implemented `RequestSizeLimitMiddleware` that enforces a maximum request body size.
|
||||
|
||||
**Implementation Details**:
|
||||
- Created `internal/server/middleware.go` with `RequestSizeLimitMiddleware`
|
||||
- Uses `http.MaxBytesReader` to enforce limits at the HTTP layer
|
||||
- Default limit: 10MB (10,485,760 bytes)
|
||||
- Configurable via `server.max_request_body_size` in config.yaml
|
||||
- Returns HTTP 413 (Request Entity Too Large) for oversized requests
|
||||
- Only applies to POST, PUT, and PATCH requests (not GET/DELETE)
|
||||
|
||||
**Files Modified**:
|
||||
- `internal/server/middleware.go` (new file)
|
||||
- `internal/server/server.go` (added 413 error handling)
|
||||
- `cmd/gateway/main.go` (integrated middleware)
|
||||
- `internal/config/config.go` (added config field)
|
||||
- `config.example.yaml` (documented configuration)
|
||||
|
||||
**Testing**:
|
||||
- Comprehensive test suite in `internal/server/middleware_test.go`
|
||||
- Tests cover: small payloads, exact size, oversized payloads, different HTTP methods
|
||||
- Integration test verifies middleware chain behavior
|
||||
|
||||
### 2. Panic Recovery Middleware (Issue #4) ✅
|
||||
|
||||
**Problem**: Any panic in HTTP handlers would crash the entire server, causing downtime.
|
||||
|
||||
**Solution**: Implemented `PanicRecoveryMiddleware` that catches panics and returns proper error responses.
|
||||
|
||||
**Implementation Details**:
|
||||
- Created `PanicRecoveryMiddleware` in `internal/server/middleware.go`
|
||||
- Uses `defer recover()` pattern to catch all panics
|
||||
- Logs full stack trace with request context for debugging
|
||||
- Returns HTTP 500 (Internal Server Error) to clients
|
||||
- Positioned as the outermost middleware to catch panics from all layers
|
||||
|
||||
**Files Modified**:
|
||||
- `internal/server/middleware.go` (new file)
|
||||
- `cmd/gateway/main.go` (integrated as outermost middleware)
|
||||
|
||||
**Testing**:
|
||||
- Tests verify recovery from string panics, error panics, and struct panics
|
||||
- Integration test confirms panic recovery works through middleware chain
|
||||
- Logs are captured and verified to include stack traces
|
||||
|
||||
### 3. Error Handling Improvements (Bonus) ✅
|
||||
|
||||
**Problem**: Multiple instances of ignored JSON encoding errors could lead to incomplete responses.
|
||||
|
||||
**Solution**: Fixed all ignored `json.Encoder.Encode()` errors throughout the codebase.
|
||||
|
||||
**Files Modified**:
|
||||
- `internal/server/health.go` (lines 32, 86)
|
||||
- `internal/server/server.go` (lines 72, 217)
|
||||
|
||||
All JSON encoding errors are now logged with proper context including request IDs.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Middleware Chain Order
|
||||
|
||||
The middleware chain is now (from outermost to innermost):
|
||||
1. **PanicRecoveryMiddleware** - Catches all panics
|
||||
2. **RequestSizeLimitMiddleware** - Enforces body size limits
|
||||
3. **loggingMiddleware** - Request/response logging
|
||||
4. **TracingMiddleware** - OpenTelemetry tracing
|
||||
5. **MetricsMiddleware** - Prometheus metrics
|
||||
6. **rateLimitMiddleware** - Rate limiting
|
||||
7. **authMiddleware** - OIDC authentication
|
||||
8. **routes** - Application handlers
|
||||
|
||||
This order ensures:
|
||||
- Panics are caught from all middleware layers
|
||||
- Size limits are enforced before expensive operations
|
||||
- All requests are logged, traced, and metered
|
||||
- Security checks happen closest to the application
|
||||
|
||||
## Configuration
|
||||
|
||||
Add to your `config.yaml`:
|
||||
|
||||
```yaml
|
||||
server:
|
||||
address: ":8080"
|
||||
max_request_body_size: 10485760 # 10MB in bytes (default)
|
||||
```
|
||||
|
||||
To customize the size limit:
|
||||
- **1MB**: `1048576`
|
||||
- **5MB**: `5242880`
|
||||
- **10MB**: `10485760` (default)
|
||||
- **50MB**: `52428800`
|
||||
|
||||
If not specified, defaults to 10MB.
|
||||
|
||||
## Testing
|
||||
|
||||
All new functionality includes comprehensive tests:
|
||||
|
||||
```bash
|
||||
# Run all tests
|
||||
go test ./...
|
||||
|
||||
# Run only middleware tests
|
||||
go test ./internal/server -v -run "TestPanicRecoveryMiddleware|TestRequestSizeLimitMiddleware"
|
||||
|
||||
# Run with coverage
|
||||
go test ./internal/server -cover
|
||||
```
|
||||
|
||||
**Test Coverage**:
|
||||
- `internal/server/middleware.go`: 100% coverage
|
||||
- All edge cases covered (panics, size limits, different HTTP methods)
|
||||
- Integration tests verify middleware chain interactions
|
||||
|
||||
## Production Readiness
|
||||
|
||||
These changes significantly improve production readiness:
|
||||
|
||||
1. **DoS Protection**: Request size limits prevent memory exhaustion attacks
|
||||
2. **Fault Tolerance**: Panic recovery prevents cascading failures
|
||||
3. **Observability**: All errors are logged with proper context
|
||||
4. **Configurability**: Limits can be tuned per deployment environment
|
||||
|
||||
## Remaining Production Concerns
|
||||
|
||||
While these issues are fixed, the following should still be addressed:
|
||||
|
||||
- **HIGH**: Exposed credentials in `.env` file (must rotate and remove from git)
|
||||
- **MEDIUM**: Observability code has 0% test coverage
|
||||
- **MEDIUM**: Conversation store has only 27% test coverage
|
||||
- **LOW**: Missing circuit breaker pattern for provider failures
|
||||
- **LOW**: No retry logic for failed provider requests
|
||||
|
||||
See the original assessment for complete details.
|
||||
|
||||
## Verification
|
||||
|
||||
Build and verify the changes:
|
||||
|
||||
```bash
|
||||
# Build the application
|
||||
go build ./cmd/gateway
|
||||
|
||||
# Run the gateway
|
||||
./gateway -config config.yaml
|
||||
|
||||
# Test with oversized payload (should return 413)
|
||||
curl -X POST http://localhost:8080/v1/responses \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$(python3 -c 'print("{\"data\":\"" + "x"*11000000 + "\"}")')"
|
||||
```
|
||||
|
||||
Expected response: `HTTP 413 Request Entity Too Large`
|
||||
|
||||
## References
|
||||
|
||||
- [OWASP: Unvalidated Redirects and Forwards](https://owasp.org/www-project-web-security-testing-guide/latest/4-Web_Application_Security_Testing/11-Client-side_Testing/04-Testing_for_Client-side_Resource_Manipulation)
|
||||
- [CWE-400: Uncontrolled Resource Consumption](https://cwe.mitre.org/data/definitions/400.html)
|
||||
- [Go HTTP Server Best Practices](https://blog.cloudflare.com/the-complete-guide-to-golang-net-http-timeouts/)
|
||||
77
go.mod
77
go.mod
@@ -3,70 +3,77 @@ module github.com/ajac-zero/latticelm
|
||||
go 1.25.7
|
||||
|
||||
require (
|
||||
github.com/alicebob/miniredis/v2 v2.37.0
|
||||
github.com/anthropics/anthropic-sdk-go v1.26.0
|
||||
github.com/go-sql-driver/mysql v1.9.3
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/jackc/pgx/v5 v5.8.0
|
||||
github.com/mattn/go-sqlite3 v1.14.34
|
||||
github.com/openai/openai-go/v3 v3.2.0
|
||||
github.com/prometheus/client_golang v1.19.0
|
||||
github.com/openai/openai-go/v3 v3.24.0
|
||||
github.com/prometheus/client_golang v1.23.2
|
||||
github.com/redis/go-redis/v9 v9.18.0
|
||||
github.com/sony/gobreaker v1.0.0
|
||||
github.com/stretchr/testify v1.11.1
|
||||
go.opentelemetry.io/otel v1.29.0
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.29.0
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.29.0
|
||||
go.opentelemetry.io/otel/sdk v1.29.0
|
||||
go.opentelemetry.io/otel/trace v1.29.0
|
||||
go.opentelemetry.io/otel v1.41.0
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.41.0
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.41.0
|
||||
go.opentelemetry.io/otel/sdk v1.41.0
|
||||
go.opentelemetry.io/otel/trace v1.41.0
|
||||
golang.org/x/time v0.14.0
|
||||
google.golang.org/genai v1.48.0
|
||||
google.golang.org/grpc v1.66.2
|
||||
google.golang.org/genai v1.49.0
|
||||
google.golang.org/grpc v1.79.1
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
require (
|
||||
cloud.google.com/go v0.116.0 // indirect
|
||||
cloud.google.com/go/auth v0.9.3 // indirect
|
||||
cloud.google.com/go/compute/metadata v0.5.0 // indirect
|
||||
filippo.io/edwards25519 v1.1.0 // indirect
|
||||
cloud.google.com/go v0.123.0 // indirect
|
||||
cloud.google.com/go/auth v0.18.2 // indirect
|
||||
cloud.google.com/go/compute/metadata v0.9.0 // indirect
|
||||
filippo.io/edwards25519 v1.2.0 // indirect
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.0 // indirect
|
||||
github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 // indirect
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
|
||||
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||
github.com/go-logr/logr v1.4.2 // indirect
|
||||
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||
github.com/go-logr/logr v1.4.3 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
|
||||
github.com/google/go-cmp v0.6.0 // indirect
|
||||
github.com/google/s2a-go v0.1.8 // indirect
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.4 // indirect
|
||||
github.com/google/go-cmp v0.7.0 // indirect
|
||||
github.com/google/s2a-go v0.1.9 // indirect
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.13 // indirect
|
||||
github.com/googleapis/gax-go/v2 v2.17.0 // indirect
|
||||
github.com/gorilla/websocket v1.5.3 // indirect
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.22.0 // indirect
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.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/kylelemons/godebug v1.1.0 // indirect
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/prometheus/client_model v0.5.0 // indirect
|
||||
github.com/prometheus/common v0.48.0 // indirect
|
||||
github.com/prometheus/procfs v0.12.0 // indirect
|
||||
github.com/prometheus/client_model v0.6.2 // indirect
|
||||
github.com/prometheus/common v0.67.5 // indirect
|
||||
github.com/prometheus/procfs v0.20.1 // indirect
|
||||
github.com/tidwall/gjson v1.18.0 // indirect
|
||||
github.com/tidwall/match v1.1.1 // indirect
|
||||
github.com/tidwall/match v1.2.0 // indirect
|
||||
github.com/tidwall/pretty v1.2.1 // indirect
|
||||
github.com/tidwall/sjson v1.2.5 // indirect
|
||||
go.opencensus.io v0.24.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.29.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.29.0 // indirect
|
||||
go.opentelemetry.io/proto/otlp v1.3.1 // indirect
|
||||
github.com/yuin/gopher-lua v1.1.1 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.66.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.41.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.41.0 // indirect
|
||||
go.opentelemetry.io/proto/otlp v1.9.0 // indirect
|
||||
go.uber.org/atomic v1.11.0 // indirect
|
||||
golang.org/x/crypto v0.47.0 // indirect
|
||||
golang.org/x/net v0.49.0 // indirect
|
||||
go.yaml.in/yaml/v2 v2.4.3 // indirect
|
||||
golang.org/x/crypto v0.48.0 // indirect
|
||||
golang.org/x/net v0.51.0 // indirect
|
||||
golang.org/x/sync v0.19.0 // indirect
|
||||
golang.org/x/sys v0.40.0 // indirect
|
||||
golang.org/x/text v0.33.0 // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20240903143218-8af14fe29dc1 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1 // indirect
|
||||
google.golang.org/protobuf v1.34.2 // indirect
|
||||
golang.org/x/sys v0.41.0 // indirect
|
||||
golang.org/x/text v0.34.0 // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260226221140-a57be14db171 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 // indirect
|
||||
google.golang.org/protobuf v1.36.11 // indirect
|
||||
)
|
||||
|
||||
250
go.sum
250
go.sum
@@ -1,12 +1,11 @@
|
||||
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
|
||||
cloud.google.com/go v0.116.0 h1:B3fRrSDkLRt5qSHWe40ERJvhvnQwdZiHu0bJOpldweE=
|
||||
cloud.google.com/go v0.116.0/go.mod h1:cEPSRWPzZEswwdr9BxE6ChEn01dWlTaF05LiC2Xs70U=
|
||||
cloud.google.com/go/auth v0.9.3 h1:VOEUIAADkkLtyfr3BLa3R8Ed/j6w1jTBmARx+wb5w5U=
|
||||
cloud.google.com/go/auth v0.9.3/go.mod h1:7z6VY+7h3KUdRov5F1i8NDP5ZzWKYmEPO842BgCsmTk=
|
||||
cloud.google.com/go/compute/metadata v0.5.0 h1:Zr0eK8JbFv6+Wi4ilXAR8FJ3wyNdpxHKJNPos6LTZOY=
|
||||
cloud.google.com/go/compute/metadata v0.5.0/go.mod h1:aHnloV2TPI38yx4s9+wAZhHykWvVCfu7hQbF+9CWoiY=
|
||||
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
||||
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
||||
cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE=
|
||||
cloud.google.com/go v0.123.0/go.mod h1:xBoMV08QcqUGuPW65Qfm1o9Y4zKZBpGS+7bImXLTAZU=
|
||||
cloud.google.com/go/auth v0.18.2 h1:+Nbt5Ev0xEqxlNjd6c+yYUeosQ5TtEUaNcN/3FozlaM=
|
||||
cloud.google.com/go/auth v0.18.2/go.mod h1:xD+oY7gcahcu7G2SG2DsBerfFxgPAJz17zz2joOFF3M=
|
||||
cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs=
|
||||
cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10=
|
||||
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
|
||||
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.0 h1:fou+2+WFTib47nS+nz/ozhEBnvU96bKHy6LjRsY4E28=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.0/go.mod h1:t76Ruy8AHvUAC8GfMWJMa0ElSbuIcO03NLpynfbgsPA=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.10.1 h1:B+blDbyVIG3WaikNxPnhPiJ1MThR03b3vKGtER95TP4=
|
||||
@@ -15,7 +14,8 @@ github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 h1:9iefClla7iYpfYWdzPCRDo
|
||||
github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2/go.mod h1:XtLgD3ZD34DAaVIIAyG3objl5DynM3CQ/vMcbBNJZGI=
|
||||
github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2 h1:oygO0locgZJe7PpYPXT5A29ZkwJaPqcva7BVeemZOZs=
|
||||
github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI=
|
||||
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
||||
github.com/alicebob/miniredis/v2 v2.37.0 h1:RheObYW32G1aiJIj81XVt78ZHJpHonHLHW7OLIshq68=
|
||||
github.com/alicebob/miniredis/v2 v2.37.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM=
|
||||
github.com/anthropics/anthropic-sdk-go v1.26.0 h1:oUTzFaUpAevfuELAP1sjL6CQJ9HHAfT7CoSYSac11PY=
|
||||
github.com/anthropics/anthropic-sdk-go v1.26.0/go.mod h1:qUKmaW+uuPB64iy1l+4kOSvaLqPXnHTTBKH6RVZ7q5Q=
|
||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||
@@ -24,13 +24,10 @@ 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.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
|
||||
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
|
||||
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
|
||||
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
|
||||
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
|
||||
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/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
|
||||
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
|
||||
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=
|
||||
@@ -38,52 +35,33 @@ 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/dnaeon/go-vcr v1.2.0 h1:zHCHvJYTMh1N7xnV7zf1m1GPBF9Ad0Jk/whtQ1663qI=
|
||||
github.com/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5OLb6RQ=
|
||||
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
||||
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
||||
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
|
||||
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
|
||||
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
|
||||
github.com/go-logr/logr v1.4.2/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-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo=
|
||||
github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
|
||||
github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE=
|
||||
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
|
||||
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
|
||||
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
|
||||
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
|
||||
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
|
||||
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
|
||||
github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8=
|
||||
github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
|
||||
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
|
||||
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/s2a-go v0.1.8 h1:zZDs9gcbt9ZPLV0ndSyQk6Kacx2g/X+SKYovpnz3SMM=
|
||||
github.com/google/s2a-go v0.1.8/go.mod h1:6iNWHTpQ+nfNRN5E00MSdfDwVesa8hhS32PhPO8deJA=
|
||||
github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||
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/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0=
|
||||
github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM=
|
||||
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/googleapis/enterprise-certificate-proxy v0.3.4 h1:XYIDZApgAnrN1c855gTgghdIA6Stxb52D5RnLI1SLyw=
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.4/go.mod h1:YKe7cfqYXjKGpGvmSg28/fFvhNzinZQm8DGnaburhGA=
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.13 h1:hSPAhW3NX+7HNlTsmrvU0jL75cIzxFktheceg95Nq14=
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.13/go.mod h1:vqVt9yG9480NtzREnTlmGSBmFrA+bzb0yl0TxoBQXOg=
|
||||
github.com/googleapis/gax-go/v2 v2.17.0 h1:RksgfBpxqff0EZkDWYuz9q/uWsTVz+kf43LsZ1J6SMc=
|
||||
github.com/googleapis/gax-go/v2 v2.17.0/go.mod h1:mzaqghpQp4JDh3HvADwrat+6M3MOIDp5YKHhb9PAgDY=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.22.0 h1:asbCHRVmodnJTuQ3qamDwqVOIjwqUPTYmYuemVOx+Ys=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.22.0/go.mod h1:ggCgvZ2r7uOoQjOyu2Y1NhHmEPPzzuhWgcza5M1Ji1I=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||
@@ -92,6 +70,8 @@ github.com/jackc/pgx/v5 v5.8.0 h1:TYPDoleBBme0xGSAX3/+NujXXtpZn9HBONkQC7IEZSo=
|
||||
github.com/jackc/pgx/v5 v5.8.0/go.mod h1:QVeDInX2m9VyzvNeiCJVjCkNFqzsNb43204HshNSZKw=
|
||||
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/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/cpuid/v2 v2.0.9 h1:lgaqFMSdTdQYdZ04uHyN2d/eKdOMyi2YLSvlQIBFYa4=
|
||||
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
@@ -102,147 +82,105 @@ github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0
|
||||
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||
github.com/mattn/go-sqlite3 v1.14.34 h1:3NtcvcUnFBPsuRcno8pUtupspG/GM+9nZ88zgJcp6Zk=
|
||||
github.com/mattn/go-sqlite3 v1.14.34/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/openai/openai-go/v3 v3.2.0 h1:2AbqFUCsoW2pm/2pUtPRuwK89dnoGHaQokzWsfoQO/U=
|
||||
github.com/openai/openai-go/v3 v3.2.0/go.mod h1:UOpNxkqC9OdNXNUfpNByKOtB4jAL0EssQXq5p8gO0Xs=
|
||||
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/openai/openai-go/v3 v3.24.0 h1:08x6GnYiB+AAejTo6yzPY8RkZMJQ8NpreiOyM5QfyYU=
|
||||
github.com/openai/openai-go/v3 v3.24.0/go.mod h1:cdufnVK14cWcT9qA1rRtrXx4FTRsgbDPW7Ia7SS5cZo=
|
||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
|
||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
|
||||
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/prometheus/client_golang v1.19.0 h1:ygXvpU1AoN1MhdzckN+PyD9QJOSD4x7kmXYlnfbA6JU=
|
||||
github.com/prometheus/client_golang v1.19.0/go.mod h1:ZRM9uEAypZakd+q/x7+gmsvXdURP+DABIEIjnmDdp+k=
|
||||
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
|
||||
github.com/prometheus/client_model v0.5.0 h1:VQw1hfvPvk3Uv6Qf29VrPF32JB6rtbgI6cYPYQjL0Qw=
|
||||
github.com/prometheus/client_model v0.5.0/go.mod h1:dTiFglRmd66nLR9Pv9f0mZi7B7fk5Pm3gvsjB5tr+kI=
|
||||
github.com/prometheus/common v0.48.0 h1:QO8U2CdOzSn1BBsmXJXduaaW+dY/5QLjfB8svtSzKKE=
|
||||
github.com/prometheus/common v0.48.0/go.mod h1:0/KsvlIEfPQCQ5I2iNSAWKPZziNCvRs5EC6ILDTlAPc=
|
||||
github.com/prometheus/procfs v0.12.0 h1:jluTpSng7V9hY0O2R9DzzJHYb2xULk9VTR1V1R/k6Bo=
|
||||
github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo=
|
||||
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=
|
||||
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
|
||||
github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4=
|
||||
github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw=
|
||||
github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc=
|
||||
github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo=
|
||||
github.com/redis/go-redis/v9 v9.18.0 h1:pMkxYPkEbMPwRdenAzUNyFNrDgHx9U+DrBabWNfSRQs=
|
||||
github.com/redis/go-redis/v9 v9.18.0/go.mod h1:k3ufPphLU5YXwNTUcCRXGxUoF1fqxnhFQmscfkCoDA0=
|
||||
github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
|
||||
github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
|
||||
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/sony/gobreaker v1.0.0 h1:feX5fGGXSl3dYd4aHZItw+FpHLvvoaqkawKjVNiFMNQ=
|
||||
github.com/sony/gobreaker v1.0.0/go.mod h1:ZKptC7FHNvhBz7dN2LGjPVBz2sZJmc0/PkyDJOjmxWY=
|
||||
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/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.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
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/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
|
||||
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
|
||||
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
|
||||
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
|
||||
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
|
||||
github.com/tidwall/match v1.2.0 h1:0pt8FlkOwjN2fPt4bIl4BoNxb98gGHN2ObFEDkrfZnM=
|
||||
github.com/tidwall/match v1.2.0/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
|
||||
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
||||
github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
|
||||
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
||||
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
|
||||
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
|
||||
github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M=
|
||||
github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw=
|
||||
github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
|
||||
github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
|
||||
go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0=
|
||||
go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo=
|
||||
go.opentelemetry.io/otel v1.29.0 h1:PdomN/Al4q/lN6iBJEN3AwPvUiHPMlt93c8bqTG5Llw=
|
||||
go.opentelemetry.io/otel v1.29.0/go.mod h1:N/WtXPs1CNCUEx+Agz5uouwCba+i+bJGFicT8SR4NP8=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.29.0 h1:dIIDULZJpgdiHz5tXrTgKIMLkus6jEFa7x5SOKcyR7E=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.29.0/go.mod h1:jlRVBe7+Z1wyxFSUs48L6OBQZ5JwH2Hg/Vbl+t9rAgI=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.29.0 h1:nSiV3s7wiCam610XcLbYOmMfJxB9gO4uK3Xgv5gmTgg=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.29.0/go.mod h1:hKn/e/Nmd19/x1gvIHwtOwVWM+VhuITSWip3JUDghj0=
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.29.0 h1:X3ZjNp36/WlkSYx0ul2jw4PtbNEDDeLskw3VPsrpYM0=
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.29.0/go.mod h1:2uL/xnOXh0CHOBFCWXz5u1A4GXLiW+0IQIzVbeOEQ0U=
|
||||
go.opentelemetry.io/otel/metric v1.29.0 h1:vPf/HFWTNkPu1aYeIsc98l4ktOQaL6LeSoeV2g+8YLc=
|
||||
go.opentelemetry.io/otel/metric v1.29.0/go.mod h1:auu/QWieFVWx+DmQOUMgj0F8LHWdgalxXqvp7BII/W8=
|
||||
go.opentelemetry.io/otel/sdk v1.29.0 h1:vkqKjk7gwhS8VaWb0POZKmIEDimRCMsopNYnriHyryo=
|
||||
go.opentelemetry.io/otel/sdk v1.29.0/go.mod h1:pM8Dx5WKnvxLCb+8lG1PRNIDxu9g9b9g59Qr7hfAAok=
|
||||
go.opentelemetry.io/otel/trace v1.29.0 h1:J/8ZNK4XgR7a21DZUAsbF8pZ5Jcw1VhACmnYt39JTi4=
|
||||
go.opentelemetry.io/otel/trace v1.29.0/go.mod h1:eHl3w0sp3paPkYstJOmAimxhiFXPg+MMTlEh3nsQgWQ=
|
||||
go.opentelemetry.io/proto/otlp v1.3.1 h1:TrMUixzpM0yuc/znrFTP9MMRh8trP93mkCiDVeXrui0=
|
||||
go.opentelemetry.io/proto/otlp v1.3.1/go.mod h1:0X1WI4de4ZsLrrJNLAQbFeLCm3T7yBkR0XqQ7niQU+8=
|
||||
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.66.0 h1:PnV4kVnw0zOmwwFkAzCN5O07fw1YOIQor120zrh0AVo=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.66.0/go.mod h1:ofAwF4uinaf8SXdVzzbL4OsxJ3VfeEg3f/F6CeF49/Y=
|
||||
go.opentelemetry.io/otel v1.41.0 h1:YlEwVsGAlCvczDILpUXpIpPSL/VPugt7zHThEMLce1c=
|
||||
go.opentelemetry.io/otel v1.41.0/go.mod h1:Yt4UwgEKeT05QbLwbyHXEwhnjxNO6D8L5PQP51/46dE=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.41.0 h1:ao6Oe+wSebTlQ1OEht7jlYTzQKE+pnx/iNywFvTbuuI=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.41.0/go.mod h1:u3T6vz0gh/NVzgDgiwkgLxpsSF6PaPmo2il0apGJbls=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.41.0 h1:mq/Qcf28TWz719lE3/hMB4KkyDuLJIvgJnFGcd0kEUI=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.41.0/go.mod h1:yk5LXEYhsL2htyDNJbEq7fWzNEigeEdV5xBF/Y+kAv0=
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.41.0 h1:61oRQmYGMW7pXmFjPg1Muy84ndqMxQ6SH2L8fBG8fSY=
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.41.0/go.mod h1:c0z2ubK4RQL+kSDuuFu9WnuXimObon3IiKjJf4NACvU=
|
||||
go.opentelemetry.io/otel/metric v1.41.0 h1:rFnDcs4gRzBcsO9tS8LCpgR0dxg4aaxWlJxCno7JlTQ=
|
||||
go.opentelemetry.io/otel/metric v1.41.0/go.mod h1:xPvCwd9pU0VN8tPZYzDZV/BMj9CM9vs00GuBjeKhJps=
|
||||
go.opentelemetry.io/otel/sdk v1.41.0 h1:YPIEXKmiAwkGl3Gu1huk1aYWwtpRLeskpV+wPisxBp8=
|
||||
go.opentelemetry.io/otel/sdk v1.41.0/go.mod h1:ahFdU0G5y8IxglBf0QBJXgSe7agzjE4GiTJ6HT9ud90=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.41.0 h1:siZQIYBAUd1rlIWQT2uCxWJxcCO7q3TriaMlf08rXw8=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.41.0/go.mod h1:HNBuSvT7ROaGtGI50ArdRLUnvRTRGniSUZbxiWxSO8Y=
|
||||
go.opentelemetry.io/otel/trace v1.41.0 h1:Vbk2co6bhj8L59ZJ6/xFTskY+tGAbOnCtQGVVa9TIN0=
|
||||
go.opentelemetry.io/otel/trace v1.41.0/go.mod h1:U1NU4ULCoxeDKc09yCWdWe+3QoyweJcISEVa1RBzOis=
|
||||
go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A=
|
||||
go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4=
|
||||
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
|
||||
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
|
||||
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
|
||||
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
||||
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
|
||||
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
|
||||
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
|
||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0=
|
||||
go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8=
|
||||
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
|
||||
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
|
||||
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-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
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-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
|
||||
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
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.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
|
||||
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
|
||||
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
||||
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-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
|
||||
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
|
||||
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
||||
google.golang.org/genai v1.48.0 h1:1vb15G291wAjJJueisMDpUhssljhEdJU2t5qTidrVPs=
|
||||
google.golang.org/genai v1.48.0/go.mod h1:A3kkl0nyBjyFlNjgxIwKq70julKbIxpSxqKO5gw/gmk=
|
||||
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
|
||||
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
|
||||
google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20240903143218-8af14fe29dc1 h1:hjSy6tcFQZ171igDaN5QHOw2n6vx40juYbC/x67CEhc=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20240903143218-8af14fe29dc1/go.mod h1:qpvKtACPCQhAdu3PyQgV4l3LMXZEtft7y8QcarRsp9I=
|
||||
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.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
|
||||
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
|
||||
google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
|
||||
google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
|
||||
google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc=
|
||||
google.golang.org/grpc v1.66.2 h1:3QdXkuq3Bkh7w+ywLdLvM56cmGvQHUMZpiCzt6Rqaoo=
|
||||
google.golang.org/grpc v1.66.2/go.mod h1:s3/l6xSSCURdVfAnL+TqCNMyTDAGN6+lZeVxnZR128Y=
|
||||
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
|
||||
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
|
||||
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
|
||||
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
|
||||
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
|
||||
google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||
google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||
google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
|
||||
google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg=
|
||||
google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw=
|
||||
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
||||
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||
google.golang.org/genai v1.49.0 h1:Se+QJaH2GYK1aaR1o5S38mlU2GD5FnVvP76nfkV7LH0=
|
||||
google.golang.org/genai v1.49.0/go.mod h1:A3kkl0nyBjyFlNjgxIwKq70julKbIxpSxqKO5gw/gmk=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260226221140-a57be14db171 h1:tu/dtnW1o3wfaxCOjSLn5IRX4YDcJrtlpzYkhHhGaC4=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260226221140-a57be14db171/go.mod h1:M5krXqk4GhBKvB596udGL3UyjL4I1+cTbK0orROM9ng=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||
google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY=
|
||||
google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
|
||||
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=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10=
|
||||
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
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=
|
||||
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||
|
||||
368
internal/conversation/redis_store_test.go
Normal file
368
internal/conversation/redis_store_test.go
Normal file
@@ -0,0 +1,368 @@
|
||||
package conversation
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/ajac-zero/latticelm/internal/api"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestNewRedisStore(t *testing.T) {
|
||||
client, mr := SetupTestRedis(t)
|
||||
defer mr.Close()
|
||||
|
||||
store := NewRedisStore(client, time.Hour)
|
||||
require.NotNil(t, store)
|
||||
|
||||
defer store.Close()
|
||||
}
|
||||
|
||||
func TestRedisStore_Create(t *testing.T) {
|
||||
client, mr := SetupTestRedis(t)
|
||||
defer mr.Close()
|
||||
|
||||
store := NewRedisStore(client, time.Hour)
|
||||
defer store.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
messages := CreateTestMessages(3)
|
||||
|
||||
conv, err := store.Create(ctx, "test-id", "test-model", messages)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, conv)
|
||||
|
||||
assert.Equal(t, "test-id", conv.ID)
|
||||
assert.Equal(t, "test-model", conv.Model)
|
||||
assert.Len(t, conv.Messages, 3)
|
||||
}
|
||||
|
||||
func TestRedisStore_Get(t *testing.T) {
|
||||
client, mr := SetupTestRedis(t)
|
||||
defer mr.Close()
|
||||
|
||||
store := NewRedisStore(client, time.Hour)
|
||||
defer store.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
messages := CreateTestMessages(2)
|
||||
|
||||
// Create a conversation
|
||||
created, err := store.Create(ctx, "get-test", "model-1", messages)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Retrieve it
|
||||
retrieved, err := store.Get(ctx, "get-test")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, retrieved)
|
||||
|
||||
assert.Equal(t, created.ID, retrieved.ID)
|
||||
assert.Equal(t, created.Model, retrieved.Model)
|
||||
assert.Len(t, retrieved.Messages, 2)
|
||||
|
||||
// Test not found
|
||||
notFound, err := store.Get(ctx, "non-existent")
|
||||
require.NoError(t, err)
|
||||
assert.Nil(t, notFound)
|
||||
}
|
||||
|
||||
func TestRedisStore_Append(t *testing.T) {
|
||||
client, mr := SetupTestRedis(t)
|
||||
defer mr.Close()
|
||||
|
||||
store := NewRedisStore(client, time.Hour)
|
||||
defer store.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
initialMessages := CreateTestMessages(2)
|
||||
|
||||
// Create conversation
|
||||
conv, err := store.Create(ctx, "append-test", "model-1", initialMessages)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, conv.Messages, 2)
|
||||
|
||||
// Append more messages
|
||||
newMessages := CreateTestMessages(3)
|
||||
updated, err := store.Append(ctx, "append-test", newMessages...)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, updated)
|
||||
|
||||
assert.Len(t, updated.Messages, 5)
|
||||
}
|
||||
|
||||
func TestRedisStore_Delete(t *testing.T) {
|
||||
client, mr := SetupTestRedis(t)
|
||||
defer mr.Close()
|
||||
|
||||
store := NewRedisStore(client, time.Hour)
|
||||
defer store.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
messages := CreateTestMessages(1)
|
||||
|
||||
// Create conversation
|
||||
_, err := store.Create(ctx, "delete-test", "model-1", messages)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify it exists
|
||||
conv, err := store.Get(ctx, "delete-test")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, conv)
|
||||
|
||||
// Delete it
|
||||
err = store.Delete(ctx, "delete-test")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify it's gone
|
||||
deleted, err := store.Get(ctx, "delete-test")
|
||||
require.NoError(t, err)
|
||||
assert.Nil(t, deleted)
|
||||
}
|
||||
|
||||
func TestRedisStore_Size(t *testing.T) {
|
||||
client, mr := SetupTestRedis(t)
|
||||
defer mr.Close()
|
||||
|
||||
store := NewRedisStore(client, time.Hour)
|
||||
defer store.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Initial size should be 0
|
||||
assert.Equal(t, 0, store.Size())
|
||||
|
||||
// Create conversations
|
||||
messages := CreateTestMessages(1)
|
||||
_, err := store.Create(ctx, "size-1", "model-1", messages)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = store.Create(ctx, "size-2", "model-1", messages)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, 2, store.Size())
|
||||
|
||||
// Delete one
|
||||
err = store.Delete(ctx, "size-1")
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, 1, store.Size())
|
||||
}
|
||||
|
||||
func TestRedisStore_TTL(t *testing.T) {
|
||||
client, mr := SetupTestRedis(t)
|
||||
defer mr.Close()
|
||||
|
||||
// Use short TTL for testing
|
||||
store := NewRedisStore(client, 100*time.Millisecond)
|
||||
defer store.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
messages := CreateTestMessages(1)
|
||||
|
||||
// Create a conversation
|
||||
_, err := store.Create(ctx, "ttl-test", "model-1", messages)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Fast forward time in miniredis
|
||||
mr.FastForward(200 * time.Millisecond)
|
||||
|
||||
// Key should have expired
|
||||
conv, err := store.Get(ctx, "ttl-test")
|
||||
require.NoError(t, err)
|
||||
assert.Nil(t, conv, "conversation should have expired")
|
||||
}
|
||||
|
||||
func TestRedisStore_KeyStorage(t *testing.T) {
|
||||
client, mr := SetupTestRedis(t)
|
||||
defer mr.Close()
|
||||
|
||||
store := NewRedisStore(client, time.Hour)
|
||||
defer store.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
messages := CreateTestMessages(1)
|
||||
|
||||
// Create conversation
|
||||
_, err := store.Create(ctx, "storage-test", "model-1", messages)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Check that key exists in Redis
|
||||
keys := mr.Keys()
|
||||
assert.Greater(t, len(keys), 0, "should have at least one key in Redis")
|
||||
}
|
||||
|
||||
func TestRedisStore_Concurrent(t *testing.T) {
|
||||
client, mr := SetupTestRedis(t)
|
||||
defer mr.Close()
|
||||
|
||||
store := NewRedisStore(client, time.Hour)
|
||||
defer store.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Run concurrent operations
|
||||
done := make(chan bool, 10)
|
||||
|
||||
for i := 0; i < 10; i++ {
|
||||
go func(idx int) {
|
||||
id := fmt.Sprintf("concurrent-%d", idx)
|
||||
messages := CreateTestMessages(2)
|
||||
|
||||
// Create
|
||||
_, err := store.Create(ctx, id, "model-1", messages)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Get
|
||||
_, err = store.Get(ctx, id)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Append
|
||||
newMsg := CreateTestMessages(1)
|
||||
_, err = store.Append(ctx, id, newMsg...)
|
||||
assert.NoError(t, err)
|
||||
|
||||
done <- true
|
||||
}(i)
|
||||
}
|
||||
|
||||
// Wait for all goroutines
|
||||
for i := 0; i < 10; i++ {
|
||||
<-done
|
||||
}
|
||||
|
||||
// Verify all conversations exist
|
||||
assert.Equal(t, 10, store.Size())
|
||||
}
|
||||
|
||||
func TestRedisStore_JSONEncoding(t *testing.T) {
|
||||
client, mr := SetupTestRedis(t)
|
||||
defer mr.Close()
|
||||
|
||||
store := NewRedisStore(client, time.Hour)
|
||||
defer store.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Create messages with various content types
|
||||
messages := []api.Message{
|
||||
{
|
||||
Role: "user",
|
||||
Content: []api.ContentBlock{
|
||||
{Type: "text", Text: "Hello"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Role: "assistant",
|
||||
Content: []api.ContentBlock{
|
||||
{Type: "text", Text: "Hi there!"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
conv, err := store.Create(ctx, "json-test", "model-1", messages)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Retrieve and verify JSON encoding/decoding
|
||||
retrieved, err := store.Get(ctx, "json-test")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, retrieved)
|
||||
|
||||
assert.Equal(t, len(conv.Messages), len(retrieved.Messages))
|
||||
assert.Equal(t, conv.Messages[0].Role, retrieved.Messages[0].Role)
|
||||
assert.Equal(t, conv.Messages[0].Content[0].Text, retrieved.Messages[0].Content[0].Text)
|
||||
}
|
||||
|
||||
func TestRedisStore_EmptyMessages(t *testing.T) {
|
||||
client, mr := SetupTestRedis(t)
|
||||
defer mr.Close()
|
||||
|
||||
store := NewRedisStore(client, time.Hour)
|
||||
defer store.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Create conversation with empty messages
|
||||
conv, err := store.Create(ctx, "empty", "model-1", []api.Message{})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, conv)
|
||||
|
||||
assert.Len(t, conv.Messages, 0)
|
||||
|
||||
// Retrieve and verify
|
||||
retrieved, err := store.Get(ctx, "empty")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, retrieved)
|
||||
|
||||
assert.Len(t, retrieved.Messages, 0)
|
||||
}
|
||||
|
||||
func TestRedisStore_UpdateExisting(t *testing.T) {
|
||||
client, mr := SetupTestRedis(t)
|
||||
defer mr.Close()
|
||||
|
||||
store := NewRedisStore(client, time.Hour)
|
||||
defer store.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
messages1 := CreateTestMessages(2)
|
||||
|
||||
// Create first version
|
||||
conv1, err := store.Create(ctx, "update-test", "model-1", messages1)
|
||||
require.NoError(t, err)
|
||||
originalTime := conv1.UpdatedAt
|
||||
|
||||
// Wait a bit
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
|
||||
// Create again with different data (overwrites)
|
||||
messages2 := CreateTestMessages(3)
|
||||
conv2, err := store.Create(ctx, "update-test", "model-2", messages2)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, "model-2", conv2.Model)
|
||||
assert.Len(t, conv2.Messages, 3)
|
||||
assert.True(t, conv2.UpdatedAt.After(originalTime))
|
||||
}
|
||||
|
||||
func TestRedisStore_ContextCancellation(t *testing.T) {
|
||||
client, mr := SetupTestRedis(t)
|
||||
defer mr.Close()
|
||||
|
||||
store := NewRedisStore(client, time.Hour)
|
||||
defer store.Close()
|
||||
|
||||
// Create a cancelled context
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
|
||||
messages := CreateTestMessages(1)
|
||||
|
||||
// Operations with cancelled context should fail or return quickly
|
||||
_, err := store.Create(ctx, "cancelled", "model-1", messages)
|
||||
// Context cancellation should be respected
|
||||
_ = err
|
||||
}
|
||||
|
||||
func TestRedisStore_ScanPagination(t *testing.T) {
|
||||
client, mr := SetupTestRedis(t)
|
||||
defer mr.Close()
|
||||
|
||||
store := NewRedisStore(client, time.Hour)
|
||||
defer store.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
messages := CreateTestMessages(1)
|
||||
|
||||
// Create multiple conversations to test scanning
|
||||
for i := 0; i < 50; i++ {
|
||||
id := fmt.Sprintf("scan-%d", i)
|
||||
_, err := store.Create(ctx, id, "model-1", messages)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// Size should count all of them
|
||||
assert.Equal(t, 50, store.Size())
|
||||
}
|
||||
@@ -148,7 +148,20 @@ func (s *SQLStore) Size() int {
|
||||
}
|
||||
|
||||
func (s *SQLStore) cleanup() {
|
||||
ticker := time.NewTicker(1 * time.Minute)
|
||||
// Calculate cleanup interval as 10% of TTL, with sensible bounds
|
||||
interval := s.ttl / 10
|
||||
|
||||
// Cap maximum interval at 1 minute for production
|
||||
if interval > 1*time.Minute {
|
||||
interval = 1 * time.Minute
|
||||
}
|
||||
|
||||
// Allow small intervals for testing (as low as 10ms)
|
||||
if interval < 10*time.Millisecond {
|
||||
interval = 10 * time.Millisecond
|
||||
}
|
||||
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
|
||||
356
internal/conversation/sql_store_test.go
Normal file
356
internal/conversation/sql_store_test.go
Normal file
@@ -0,0 +1,356 @@
|
||||
package conversation
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/ajac-zero/latticelm/internal/api"
|
||||
)
|
||||
|
||||
func setupSQLiteDB(t *testing.T) *sql.DB {
|
||||
t.Helper()
|
||||
db, err := sql.Open("sqlite3", ":memory:")
|
||||
require.NoError(t, err)
|
||||
return db
|
||||
}
|
||||
|
||||
func TestNewSQLStore(t *testing.T) {
|
||||
db := setupSQLiteDB(t)
|
||||
defer db.Close()
|
||||
|
||||
store, err := NewSQLStore(db, "sqlite3", time.Hour)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, store)
|
||||
|
||||
defer store.Close()
|
||||
|
||||
// Verify table was created
|
||||
var tableName string
|
||||
err = db.QueryRow("SELECT name FROM sqlite_master WHERE type='table' AND name='conversations'").Scan(&tableName)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "conversations", tableName)
|
||||
}
|
||||
|
||||
func TestSQLStore_Create(t *testing.T) {
|
||||
db := setupSQLiteDB(t)
|
||||
defer db.Close()
|
||||
|
||||
store, err := NewSQLStore(db, "sqlite3", time.Hour)
|
||||
require.NoError(t, err)
|
||||
defer store.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
messages := CreateTestMessages(3)
|
||||
|
||||
conv, err := store.Create(ctx, "test-id", "test-model", messages)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, conv)
|
||||
|
||||
assert.Equal(t, "test-id", conv.ID)
|
||||
assert.Equal(t, "test-model", conv.Model)
|
||||
assert.Len(t, conv.Messages, 3)
|
||||
}
|
||||
|
||||
func TestSQLStore_Get(t *testing.T) {
|
||||
db := setupSQLiteDB(t)
|
||||
defer db.Close()
|
||||
|
||||
store, err := NewSQLStore(db, "sqlite3", time.Hour)
|
||||
require.NoError(t, err)
|
||||
defer store.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
messages := CreateTestMessages(2)
|
||||
|
||||
// Create a conversation
|
||||
created, err := store.Create(ctx, "get-test", "model-1", messages)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Retrieve it
|
||||
retrieved, err := store.Get(ctx, "get-test")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, retrieved)
|
||||
|
||||
assert.Equal(t, created.ID, retrieved.ID)
|
||||
assert.Equal(t, created.Model, retrieved.Model)
|
||||
assert.Len(t, retrieved.Messages, 2)
|
||||
|
||||
// Test not found
|
||||
notFound, err := store.Get(ctx, "non-existent")
|
||||
require.NoError(t, err)
|
||||
assert.Nil(t, notFound)
|
||||
}
|
||||
|
||||
func TestSQLStore_Append(t *testing.T) {
|
||||
db := setupSQLiteDB(t)
|
||||
defer db.Close()
|
||||
|
||||
store, err := NewSQLStore(db, "sqlite3", time.Hour)
|
||||
require.NoError(t, err)
|
||||
defer store.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
initialMessages := CreateTestMessages(2)
|
||||
|
||||
// Create conversation
|
||||
conv, err := store.Create(ctx, "append-test", "model-1", initialMessages)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, conv.Messages, 2)
|
||||
|
||||
// Append more messages
|
||||
newMessages := CreateTestMessages(3)
|
||||
updated, err := store.Append(ctx, "append-test", newMessages...)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, updated)
|
||||
|
||||
assert.Len(t, updated.Messages, 5)
|
||||
}
|
||||
|
||||
func TestSQLStore_Delete(t *testing.T) {
|
||||
db := setupSQLiteDB(t)
|
||||
defer db.Close()
|
||||
|
||||
store, err := NewSQLStore(db, "sqlite3", time.Hour)
|
||||
require.NoError(t, err)
|
||||
defer store.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
messages := CreateTestMessages(1)
|
||||
|
||||
// Create conversation
|
||||
_, err = store.Create(ctx, "delete-test", "model-1", messages)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify it exists
|
||||
conv, err := store.Get(ctx, "delete-test")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, conv)
|
||||
|
||||
// Delete it
|
||||
err = store.Delete(ctx, "delete-test")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify it's gone
|
||||
deleted, err := store.Get(ctx, "delete-test")
|
||||
require.NoError(t, err)
|
||||
assert.Nil(t, deleted)
|
||||
}
|
||||
|
||||
func TestSQLStore_Size(t *testing.T) {
|
||||
db := setupSQLiteDB(t)
|
||||
defer db.Close()
|
||||
|
||||
store, err := NewSQLStore(db, "sqlite3", time.Hour)
|
||||
require.NoError(t, err)
|
||||
defer store.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Initial size should be 0
|
||||
assert.Equal(t, 0, store.Size())
|
||||
|
||||
// Create conversations
|
||||
messages := CreateTestMessages(1)
|
||||
_, err = store.Create(ctx, "size-1", "model-1", messages)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = store.Create(ctx, "size-2", "model-1", messages)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, 2, store.Size())
|
||||
|
||||
// Delete one
|
||||
err = store.Delete(ctx, "size-1")
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, 1, store.Size())
|
||||
}
|
||||
|
||||
func TestSQLStore_Cleanup(t *testing.T) {
|
||||
db := setupSQLiteDB(t)
|
||||
defer db.Close()
|
||||
|
||||
// Use very short TTL for testing
|
||||
store, err := NewSQLStore(db, "sqlite3", 100*time.Millisecond)
|
||||
require.NoError(t, err)
|
||||
defer store.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
messages := CreateTestMessages(1)
|
||||
|
||||
// Create a conversation
|
||||
_, err = store.Create(ctx, "cleanup-test", "model-1", messages)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, 1, store.Size())
|
||||
|
||||
// Wait for TTL to expire and cleanup to run
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
|
||||
// Conversation should be cleaned up
|
||||
assert.Equal(t, 0, store.Size())
|
||||
}
|
||||
|
||||
func TestSQLStore_ConcurrentAccess(t *testing.T) {
|
||||
db := setupSQLiteDB(t)
|
||||
defer db.Close()
|
||||
|
||||
store, err := NewSQLStore(db, "sqlite3", time.Hour)
|
||||
require.NoError(t, err)
|
||||
defer store.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Run concurrent operations
|
||||
done := make(chan bool, 10)
|
||||
|
||||
for i := 0; i < 10; i++ {
|
||||
go func(idx int) {
|
||||
id := fmt.Sprintf("concurrent-%d", idx)
|
||||
messages := CreateTestMessages(2)
|
||||
|
||||
// Create
|
||||
_, err := store.Create(ctx, id, "model-1", messages)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Get
|
||||
_, err = store.Get(ctx, id)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Append
|
||||
newMsg := CreateTestMessages(1)
|
||||
_, err = store.Append(ctx, id, newMsg...)
|
||||
assert.NoError(t, err)
|
||||
|
||||
done <- true
|
||||
}(i)
|
||||
}
|
||||
|
||||
// Wait for all goroutines
|
||||
for i := 0; i < 10; i++ {
|
||||
<-done
|
||||
}
|
||||
|
||||
// Verify all conversations exist
|
||||
assert.Equal(t, 10, store.Size())
|
||||
}
|
||||
|
||||
func TestSQLStore_ContextCancellation(t *testing.T) {
|
||||
db := setupSQLiteDB(t)
|
||||
defer db.Close()
|
||||
|
||||
store, err := NewSQLStore(db, "sqlite3", time.Hour)
|
||||
require.NoError(t, err)
|
||||
defer store.Close()
|
||||
|
||||
// Create a cancelled context
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
|
||||
messages := CreateTestMessages(1)
|
||||
|
||||
// Operations with cancelled context should fail or return quickly
|
||||
_, err = store.Create(ctx, "cancelled", "model-1", messages)
|
||||
// Error handling depends on driver, but context should be respected
|
||||
_ = err
|
||||
}
|
||||
|
||||
func TestSQLStore_JSONEncoding(t *testing.T) {
|
||||
db := setupSQLiteDB(t)
|
||||
defer db.Close()
|
||||
|
||||
store, err := NewSQLStore(db, "sqlite3", time.Hour)
|
||||
require.NoError(t, err)
|
||||
defer store.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Create messages with various content types
|
||||
messages := []api.Message{
|
||||
{
|
||||
Role: "user",
|
||||
Content: []api.ContentBlock{
|
||||
{Type: "text", Text: "Hello"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Role: "assistant",
|
||||
Content: []api.ContentBlock{
|
||||
{Type: "text", Text: "Hi there!"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
conv, err := store.Create(ctx, "json-test", "model-1", messages)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Retrieve and verify JSON encoding/decoding
|
||||
retrieved, err := store.Get(ctx, "json-test")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, retrieved)
|
||||
|
||||
assert.Equal(t, len(conv.Messages), len(retrieved.Messages))
|
||||
assert.Equal(t, conv.Messages[0].Role, retrieved.Messages[0].Role)
|
||||
assert.Equal(t, conv.Messages[0].Content[0].Text, retrieved.Messages[0].Content[0].Text)
|
||||
}
|
||||
|
||||
func TestSQLStore_EmptyMessages(t *testing.T) {
|
||||
db := setupSQLiteDB(t)
|
||||
defer db.Close()
|
||||
|
||||
store, err := NewSQLStore(db, "sqlite3", time.Hour)
|
||||
require.NoError(t, err)
|
||||
defer store.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Create conversation with empty messages
|
||||
conv, err := store.Create(ctx, "empty", "model-1", []api.Message{})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, conv)
|
||||
|
||||
assert.Len(t, conv.Messages, 0)
|
||||
|
||||
// Retrieve and verify
|
||||
retrieved, err := store.Get(ctx, "empty")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, retrieved)
|
||||
|
||||
assert.Len(t, retrieved.Messages, 0)
|
||||
}
|
||||
|
||||
func TestSQLStore_UpdateExisting(t *testing.T) {
|
||||
db := setupSQLiteDB(t)
|
||||
defer db.Close()
|
||||
|
||||
store, err := NewSQLStore(db, "sqlite3", time.Hour)
|
||||
require.NoError(t, err)
|
||||
defer store.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
messages1 := CreateTestMessages(2)
|
||||
|
||||
// Create first version
|
||||
conv1, err := store.Create(ctx, "update-test", "model-1", messages1)
|
||||
require.NoError(t, err)
|
||||
originalTime := conv1.UpdatedAt
|
||||
|
||||
// Wait a bit
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
|
||||
// Create again with different data (upsert)
|
||||
messages2 := CreateTestMessages(3)
|
||||
conv2, err := store.Create(ctx, "update-test", "model-2", messages2)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, "model-2", conv2.Model)
|
||||
assert.Len(t, conv2.Messages, 3)
|
||||
assert.True(t, conv2.UpdatedAt.After(originalTime))
|
||||
}
|
||||
172
internal/conversation/testing.go
Normal file
172
internal/conversation/testing.go
Normal file
@@ -0,0 +1,172 @@
|
||||
package conversation
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/alicebob/miniredis/v2"
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
"github.com/redis/go-redis/v9"
|
||||
|
||||
"github.com/ajac-zero/latticelm/internal/api"
|
||||
)
|
||||
|
||||
// SetupTestDB creates an in-memory SQLite database for testing
|
||||
func SetupTestDB(t *testing.T, driver string) *sql.DB {
|
||||
t.Helper()
|
||||
|
||||
var dsn string
|
||||
switch driver {
|
||||
case "sqlite3":
|
||||
// Use in-memory SQLite database
|
||||
dsn = ":memory:"
|
||||
case "postgres":
|
||||
// For postgres tests, use a mock or skip
|
||||
t.Skip("PostgreSQL tests require external database")
|
||||
return nil
|
||||
case "mysql":
|
||||
// For mysql tests, use a mock or skip
|
||||
t.Skip("MySQL tests require external database")
|
||||
return nil
|
||||
default:
|
||||
t.Fatalf("unsupported driver: %s", driver)
|
||||
return nil
|
||||
}
|
||||
|
||||
db, err := sql.Open(driver, dsn)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to open database: %v", err)
|
||||
}
|
||||
|
||||
// Create the conversations table
|
||||
schema := `
|
||||
CREATE TABLE IF NOT EXISTS conversations (
|
||||
conversation_id TEXT PRIMARY KEY,
|
||||
messages TEXT NOT NULL,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`
|
||||
if _, err := db.Exec(schema); err != nil {
|
||||
db.Close()
|
||||
t.Fatalf("failed to create schema: %v", err)
|
||||
}
|
||||
|
||||
return db
|
||||
}
|
||||
|
||||
// SetupTestRedis creates a miniredis instance for testing
|
||||
func SetupTestRedis(t *testing.T) (*redis.Client, *miniredis.Miniredis) {
|
||||
t.Helper()
|
||||
|
||||
mr := miniredis.RunT(t)
|
||||
|
||||
client := redis.NewClient(&redis.Options{
|
||||
Addr: mr.Addr(),
|
||||
})
|
||||
|
||||
// Test connection
|
||||
ctx := context.Background()
|
||||
if err := client.Ping(ctx).Err(); err != nil {
|
||||
t.Fatalf("failed to connect to miniredis: %v", err)
|
||||
}
|
||||
|
||||
return client, mr
|
||||
}
|
||||
|
||||
// CreateTestMessages generates test message fixtures
|
||||
func CreateTestMessages(count int) []api.Message {
|
||||
messages := make([]api.Message, count)
|
||||
for i := 0; i < count; i++ {
|
||||
role := "user"
|
||||
if i%2 == 1 {
|
||||
role = "assistant"
|
||||
}
|
||||
messages[i] = api.Message{
|
||||
Role: role,
|
||||
Content: []api.ContentBlock{
|
||||
{
|
||||
Type: "text",
|
||||
Text: fmt.Sprintf("Test message %d", i+1),
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
return messages
|
||||
}
|
||||
|
||||
// CreateTestConversation creates a test conversation with the given ID and messages
|
||||
func CreateTestConversation(conversationID string, messageCount int) *Conversation {
|
||||
return &Conversation{
|
||||
ID: conversationID,
|
||||
Messages: CreateTestMessages(messageCount),
|
||||
Model: "test-model",
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
}
|
||||
|
||||
// MockStore is a simple in-memory store for testing
|
||||
type MockStore struct {
|
||||
conversations map[string]*Conversation
|
||||
getCalled bool
|
||||
createCalled bool
|
||||
appendCalled bool
|
||||
deleteCalled bool
|
||||
sizeCalled bool
|
||||
}
|
||||
|
||||
func NewMockStore() *MockStore {
|
||||
return &MockStore{
|
||||
conversations: make(map[string]*Conversation),
|
||||
}
|
||||
}
|
||||
|
||||
func (m *MockStore) Get(ctx context.Context, conversationID string) (*Conversation, error) {
|
||||
m.getCalled = true
|
||||
conv, ok := m.conversations[conversationID]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("conversation not found")
|
||||
}
|
||||
return conv, nil
|
||||
}
|
||||
|
||||
func (m *MockStore) Create(ctx context.Context, conversationID string, model string, messages []api.Message) (*Conversation, error) {
|
||||
m.createCalled = true
|
||||
m.conversations[conversationID] = &Conversation{
|
||||
ID: conversationID,
|
||||
Model: model,
|
||||
Messages: messages,
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
return m.conversations[conversationID], nil
|
||||
}
|
||||
|
||||
func (m *MockStore) Append(ctx context.Context, conversationID string, messages ...api.Message) (*Conversation, error) {
|
||||
m.appendCalled = true
|
||||
conv, ok := m.conversations[conversationID]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("conversation not found")
|
||||
}
|
||||
conv.Messages = append(conv.Messages, messages...)
|
||||
conv.UpdatedAt = time.Now()
|
||||
return conv, nil
|
||||
}
|
||||
|
||||
func (m *MockStore) Delete(ctx context.Context, conversationID string) error {
|
||||
m.deleteCalled = true
|
||||
delete(m.conversations, conversationID)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *MockStore) Size() int {
|
||||
m.sizeCalled = true
|
||||
return len(m.conversations)
|
||||
}
|
||||
|
||||
func (m *MockStore) Close() error {
|
||||
return nil
|
||||
}
|
||||
424
internal/observability/metrics_test.go
Normal file
424
internal/observability/metrics_test.go
Normal file
@@ -0,0 +1,424 @@
|
||||
package observability
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/prometheus/client_golang/prometheus/testutil"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestInitMetrics(t *testing.T) {
|
||||
// Test that InitMetrics returns a non-nil registry
|
||||
registry := InitMetrics()
|
||||
require.NotNil(t, registry, "InitMetrics should return a non-nil registry")
|
||||
|
||||
// Test that we can gather metrics from the registry (may be empty if no metrics recorded)
|
||||
metricFamilies, err := registry.Gather()
|
||||
require.NoError(t, err, "Gathering metrics should not error")
|
||||
|
||||
// Just verify that the registry is functional
|
||||
// We cannot test specific metrics as they are package-level variables that may already be registered elsewhere
|
||||
_ = metricFamilies
|
||||
}
|
||||
|
||||
func TestRecordCircuitBreakerStateChange(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
provider string
|
||||
from string
|
||||
to string
|
||||
expectedState float64
|
||||
}{
|
||||
{
|
||||
name: "transition to closed",
|
||||
provider: "openai",
|
||||
from: "open",
|
||||
to: "closed",
|
||||
expectedState: 0,
|
||||
},
|
||||
{
|
||||
name: "transition to open",
|
||||
provider: "anthropic",
|
||||
from: "closed",
|
||||
to: "open",
|
||||
expectedState: 1,
|
||||
},
|
||||
{
|
||||
name: "transition to half-open",
|
||||
provider: "google",
|
||||
from: "open",
|
||||
to: "half-open",
|
||||
expectedState: 2,
|
||||
},
|
||||
{
|
||||
name: "closed to half-open",
|
||||
provider: "openai",
|
||||
from: "closed",
|
||||
to: "half-open",
|
||||
expectedState: 2,
|
||||
},
|
||||
{
|
||||
name: "half-open to closed",
|
||||
provider: "anthropic",
|
||||
from: "half-open",
|
||||
to: "closed",
|
||||
expectedState: 0,
|
||||
},
|
||||
{
|
||||
name: "half-open to open",
|
||||
provider: "google",
|
||||
from: "half-open",
|
||||
to: "open",
|
||||
expectedState: 1,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Reset metrics for this test
|
||||
circuitBreakerStateTransitions.Reset()
|
||||
circuitBreakerState.Reset()
|
||||
|
||||
// Record the state change
|
||||
RecordCircuitBreakerStateChange(tt.provider, tt.from, tt.to)
|
||||
|
||||
// Verify the transition counter was incremented
|
||||
transitionMetric := circuitBreakerStateTransitions.WithLabelValues(tt.provider, tt.from, tt.to)
|
||||
value := testutil.ToFloat64(transitionMetric)
|
||||
assert.Equal(t, 1.0, value, "transition counter should be incremented")
|
||||
|
||||
// Verify the state gauge was set correctly
|
||||
stateMetric := circuitBreakerState.WithLabelValues(tt.provider)
|
||||
stateValue := testutil.ToFloat64(stateMetric)
|
||||
assert.Equal(t, tt.expectedState, stateValue, "state gauge should reflect new state")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMetricLabels(t *testing.T) {
|
||||
// Initialize a fresh registry for testing
|
||||
registry := prometheus.NewRegistry()
|
||||
|
||||
// Create new metric for testing labels
|
||||
testCounter := prometheus.NewCounterVec(
|
||||
prometheus.CounterOpts{
|
||||
Name: "test_counter",
|
||||
Help: "Test counter for label verification",
|
||||
},
|
||||
[]string{"label1", "label2"},
|
||||
)
|
||||
registry.MustRegister(testCounter)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
label1 string
|
||||
label2 string
|
||||
incr float64
|
||||
}{
|
||||
{
|
||||
name: "basic labels",
|
||||
label1: "value1",
|
||||
label2: "value2",
|
||||
incr: 1.0,
|
||||
},
|
||||
{
|
||||
name: "different labels",
|
||||
label1: "foo",
|
||||
label2: "bar",
|
||||
incr: 5.0,
|
||||
},
|
||||
{
|
||||
name: "empty labels",
|
||||
label1: "",
|
||||
label2: "",
|
||||
incr: 2.0,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
counter := testCounter.WithLabelValues(tt.label1, tt.label2)
|
||||
counter.Add(tt.incr)
|
||||
|
||||
value := testutil.ToFloat64(counter)
|
||||
assert.Equal(t, tt.incr, value, "counter value should match increment")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPMetrics(t *testing.T) {
|
||||
// Reset metrics
|
||||
httpRequestsTotal.Reset()
|
||||
httpRequestDuration.Reset()
|
||||
httpRequestSize.Reset()
|
||||
httpResponseSize.Reset()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
method string
|
||||
path string
|
||||
status string
|
||||
}{
|
||||
{
|
||||
name: "GET request",
|
||||
method: "GET",
|
||||
path: "/api/v1/chat",
|
||||
status: "200",
|
||||
},
|
||||
{
|
||||
name: "POST request",
|
||||
method: "POST",
|
||||
path: "/api/v1/generate",
|
||||
status: "201",
|
||||
},
|
||||
{
|
||||
name: "error response",
|
||||
method: "POST",
|
||||
path: "/api/v1/chat",
|
||||
status: "500",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Simulate recording HTTP metrics
|
||||
httpRequestsTotal.WithLabelValues(tt.method, tt.path, tt.status).Inc()
|
||||
httpRequestDuration.WithLabelValues(tt.method, tt.path, tt.status).Observe(0.5)
|
||||
httpRequestSize.WithLabelValues(tt.method, tt.path).Observe(1024)
|
||||
httpResponseSize.WithLabelValues(tt.method, tt.path).Observe(2048)
|
||||
|
||||
// Verify counter
|
||||
counter := httpRequestsTotal.WithLabelValues(tt.method, tt.path, tt.status)
|
||||
value := testutil.ToFloat64(counter)
|
||||
assert.Greater(t, value, 0.0, "request counter should be incremented")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderMetrics(t *testing.T) {
|
||||
// Reset metrics
|
||||
providerRequestsTotal.Reset()
|
||||
providerRequestDuration.Reset()
|
||||
providerTokensTotal.Reset()
|
||||
providerStreamTTFB.Reset()
|
||||
providerStreamChunks.Reset()
|
||||
providerStreamDuration.Reset()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
provider string
|
||||
model string
|
||||
operation string
|
||||
status string
|
||||
}{
|
||||
{
|
||||
name: "OpenAI generate success",
|
||||
provider: "openai",
|
||||
model: "gpt-4",
|
||||
operation: "generate",
|
||||
status: "success",
|
||||
},
|
||||
{
|
||||
name: "Anthropic stream success",
|
||||
provider: "anthropic",
|
||||
model: "claude-3-sonnet",
|
||||
operation: "stream",
|
||||
status: "success",
|
||||
},
|
||||
{
|
||||
name: "Google generate error",
|
||||
provider: "google",
|
||||
model: "gemini-pro",
|
||||
operation: "generate",
|
||||
status: "error",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Simulate recording provider metrics
|
||||
providerRequestsTotal.WithLabelValues(tt.provider, tt.model, tt.operation, tt.status).Inc()
|
||||
providerRequestDuration.WithLabelValues(tt.provider, tt.model, tt.operation).Observe(1.5)
|
||||
providerTokensTotal.WithLabelValues(tt.provider, tt.model, "input").Add(100)
|
||||
providerTokensTotal.WithLabelValues(tt.provider, tt.model, "output").Add(50)
|
||||
|
||||
if tt.operation == "stream" {
|
||||
providerStreamTTFB.WithLabelValues(tt.provider, tt.model).Observe(0.2)
|
||||
providerStreamChunks.WithLabelValues(tt.provider, tt.model).Add(10)
|
||||
providerStreamDuration.WithLabelValues(tt.provider, tt.model).Observe(2.0)
|
||||
}
|
||||
|
||||
// Verify counter
|
||||
counter := providerRequestsTotal.WithLabelValues(tt.provider, tt.model, tt.operation, tt.status)
|
||||
value := testutil.ToFloat64(counter)
|
||||
assert.Greater(t, value, 0.0, "request counter should be incremented")
|
||||
|
||||
// Verify token counts
|
||||
inputTokens := providerTokensTotal.WithLabelValues(tt.provider, tt.model, "input")
|
||||
inputValue := testutil.ToFloat64(inputTokens)
|
||||
assert.Greater(t, inputValue, 0.0, "input tokens should be recorded")
|
||||
|
||||
outputTokens := providerTokensTotal.WithLabelValues(tt.provider, tt.model, "output")
|
||||
outputValue := testutil.ToFloat64(outputTokens)
|
||||
assert.Greater(t, outputValue, 0.0, "output tokens should be recorded")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestConversationStoreMetrics(t *testing.T) {
|
||||
// Reset metrics
|
||||
conversationOperationsTotal.Reset()
|
||||
conversationOperationDuration.Reset()
|
||||
conversationActiveCount.Reset()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
operation string
|
||||
backend string
|
||||
status string
|
||||
}{
|
||||
{
|
||||
name: "create success",
|
||||
operation: "create",
|
||||
backend: "redis",
|
||||
status: "success",
|
||||
},
|
||||
{
|
||||
name: "get success",
|
||||
operation: "get",
|
||||
backend: "sql",
|
||||
status: "success",
|
||||
},
|
||||
{
|
||||
name: "delete error",
|
||||
operation: "delete",
|
||||
backend: "memory",
|
||||
status: "error",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Simulate recording store metrics
|
||||
conversationOperationsTotal.WithLabelValues(tt.operation, tt.backend, tt.status).Inc()
|
||||
conversationOperationDuration.WithLabelValues(tt.operation, tt.backend).Observe(0.01)
|
||||
|
||||
if tt.operation == "create" {
|
||||
conversationActiveCount.WithLabelValues(tt.backend).Inc()
|
||||
} else if tt.operation == "delete" {
|
||||
conversationActiveCount.WithLabelValues(tt.backend).Dec()
|
||||
}
|
||||
|
||||
// Verify counter
|
||||
counter := conversationOperationsTotal.WithLabelValues(tt.operation, tt.backend, tt.status)
|
||||
value := testutil.ToFloat64(counter)
|
||||
assert.Greater(t, value, 0.0, "operation counter should be incremented")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMetricHelp(t *testing.T) {
|
||||
registry := InitMetrics()
|
||||
metricFamilies, err := registry.Gather()
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify that all metrics have help text
|
||||
for _, mf := range metricFamilies {
|
||||
assert.NotEmpty(t, mf.GetHelp(), "metric %s should have help text", mf.GetName())
|
||||
}
|
||||
}
|
||||
|
||||
func TestMetricTypes(t *testing.T) {
|
||||
registry := InitMetrics()
|
||||
metricFamilies, err := registry.Gather()
|
||||
require.NoError(t, err)
|
||||
|
||||
metricTypes := make(map[string]string)
|
||||
for _, mf := range metricFamilies {
|
||||
metricTypes[mf.GetName()] = mf.GetType().String()
|
||||
}
|
||||
|
||||
// Verify counter metrics
|
||||
counterMetrics := []string{
|
||||
"http_requests_total",
|
||||
"provider_requests_total",
|
||||
"provider_tokens_total",
|
||||
"provider_stream_chunks_total",
|
||||
"conversation_operations_total",
|
||||
"circuit_breaker_state_transitions_total",
|
||||
}
|
||||
for _, metric := range counterMetrics {
|
||||
assert.Equal(t, "COUNTER", metricTypes[metric], "metric %s should be a counter", metric)
|
||||
}
|
||||
|
||||
// Verify histogram metrics
|
||||
histogramMetrics := []string{
|
||||
"http_request_duration_seconds",
|
||||
"http_request_size_bytes",
|
||||
"http_response_size_bytes",
|
||||
"provider_request_duration_seconds",
|
||||
"provider_stream_ttfb_seconds",
|
||||
"provider_stream_duration_seconds",
|
||||
"conversation_operation_duration_seconds",
|
||||
}
|
||||
for _, metric := range histogramMetrics {
|
||||
assert.Equal(t, "HISTOGRAM", metricTypes[metric], "metric %s should be a histogram", metric)
|
||||
}
|
||||
|
||||
// Verify gauge metrics
|
||||
gaugeMetrics := []string{
|
||||
"conversation_active_count",
|
||||
"circuit_breaker_state",
|
||||
}
|
||||
for _, metric := range gaugeMetrics {
|
||||
assert.Equal(t, "GAUGE", metricTypes[metric], "metric %s should be a gauge", metric)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCircuitBreakerInvalidState(t *testing.T) {
|
||||
// Reset metrics
|
||||
circuitBreakerState.Reset()
|
||||
circuitBreakerStateTransitions.Reset()
|
||||
|
||||
// Record a state change with an unknown target state
|
||||
RecordCircuitBreakerStateChange("test-provider", "closed", "unknown")
|
||||
|
||||
// The transition should still be recorded
|
||||
transitionMetric := circuitBreakerStateTransitions.WithLabelValues("test-provider", "closed", "unknown")
|
||||
value := testutil.ToFloat64(transitionMetric)
|
||||
assert.Equal(t, 1.0, value, "transition should be recorded even for unknown state")
|
||||
|
||||
// The state gauge should be 0 (default for unknown states)
|
||||
stateMetric := circuitBreakerState.WithLabelValues("test-provider")
|
||||
stateValue := testutil.ToFloat64(stateMetric)
|
||||
assert.Equal(t, 0.0, stateValue, "unknown state should default to 0")
|
||||
}
|
||||
|
||||
func TestMetricNaming(t *testing.T) {
|
||||
registry := InitMetrics()
|
||||
metricFamilies, err := registry.Gather()
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify metric naming conventions
|
||||
for _, mf := range metricFamilies {
|
||||
name := mf.GetName()
|
||||
|
||||
// Counter metrics should end with _total
|
||||
if strings.HasSuffix(name, "_total") {
|
||||
assert.Equal(t, "COUNTER", mf.GetType().String(), "metric %s ends with _total but is not a counter", name)
|
||||
}
|
||||
|
||||
// Duration metrics should end with _seconds
|
||||
if strings.Contains(name, "duration") {
|
||||
assert.True(t, strings.HasSuffix(name, "_seconds"), "duration metric %s should end with _seconds", name)
|
||||
}
|
||||
|
||||
// Size metrics should end with _bytes
|
||||
if strings.Contains(name, "size") {
|
||||
assert.True(t, strings.HasSuffix(name, "_bytes"), "size metric %s should end with _bytes", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -132,11 +132,8 @@ func (p *InstrumentedProvider) GenerateStream(ctx context.Context, messages []ap
|
||||
defer close(outChan)
|
||||
defer close(outErrChan)
|
||||
|
||||
for {
|
||||
select {
|
||||
case delta, ok := <-baseChan:
|
||||
if !ok {
|
||||
// Stream finished - record final metrics
|
||||
// Helper function to record final metrics
|
||||
recordMetrics := func() {
|
||||
duration := time.Since(start).Seconds()
|
||||
status := "success"
|
||||
if streamErr != nil {
|
||||
@@ -174,6 +171,14 @@ func (p *InstrumentedProvider) GenerateStream(ctx context.Context, messages []ap
|
||||
providerStreamTTFB.WithLabelValues(p.base.Name(), req.Model).Observe(ttfb.Seconds())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for {
|
||||
select {
|
||||
case delta, ok := <-baseChan:
|
||||
if !ok {
|
||||
// Stream finished - record final metrics
|
||||
recordMetrics()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -198,9 +203,11 @@ func (p *InstrumentedProvider) GenerateStream(ctx context.Context, messages []ap
|
||||
if ok && err != nil {
|
||||
streamErr = err
|
||||
outErrChan <- err
|
||||
}
|
||||
recordMetrics()
|
||||
return
|
||||
}
|
||||
// If error channel closed without error, continue draining baseChan
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
|
||||
706
internal/observability/provider_wrapper_test.go
Normal file
706
internal/observability/provider_wrapper_test.go
Normal file
@@ -0,0 +1,706 @@
|
||||
package observability
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/ajac-zero/latticelm/internal/api"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/prometheus/client_golang/prometheus/testutil"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.opentelemetry.io/otel/codes"
|
||||
sdktrace "go.opentelemetry.io/otel/sdk/trace"
|
||||
)
|
||||
|
||||
// mockBaseProvider implements providers.Provider for testing
|
||||
type mockBaseProvider struct {
|
||||
name string
|
||||
generateFunc func(ctx context.Context, messages []api.Message, req *api.ResponseRequest) (*api.ProviderResult, error)
|
||||
streamFunc func(ctx context.Context, messages []api.Message, req *api.ResponseRequest) (<-chan *api.ProviderStreamDelta, <-chan error)
|
||||
callCount int
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func newMockBaseProvider(name string) *mockBaseProvider {
|
||||
return &mockBaseProvider{
|
||||
name: name,
|
||||
}
|
||||
}
|
||||
|
||||
func (m *mockBaseProvider) Name() string {
|
||||
return m.name
|
||||
}
|
||||
|
||||
func (m *mockBaseProvider) Generate(ctx context.Context, messages []api.Message, req *api.ResponseRequest) (*api.ProviderResult, error) {
|
||||
m.mu.Lock()
|
||||
m.callCount++
|
||||
m.mu.Unlock()
|
||||
|
||||
if m.generateFunc != nil {
|
||||
return m.generateFunc(ctx, messages, req)
|
||||
}
|
||||
|
||||
// Default successful response
|
||||
return &api.ProviderResult{
|
||||
ID: "test-id",
|
||||
Model: req.Model,
|
||||
Text: "test response",
|
||||
Usage: api.Usage{
|
||||
InputTokens: 100,
|
||||
OutputTokens: 50,
|
||||
TotalTokens: 150,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (m *mockBaseProvider) GenerateStream(ctx context.Context, messages []api.Message, req *api.ResponseRequest) (<-chan *api.ProviderStreamDelta, <-chan error) {
|
||||
m.mu.Lock()
|
||||
m.callCount++
|
||||
m.mu.Unlock()
|
||||
|
||||
if m.streamFunc != nil {
|
||||
return m.streamFunc(ctx, messages, req)
|
||||
}
|
||||
|
||||
// Default streaming response
|
||||
deltaChan := make(chan *api.ProviderStreamDelta, 3)
|
||||
errChan := make(chan error, 1)
|
||||
|
||||
go func() {
|
||||
defer close(deltaChan)
|
||||
defer close(errChan)
|
||||
|
||||
deltaChan <- &api.ProviderStreamDelta{
|
||||
Model: req.Model,
|
||||
Text: "chunk1",
|
||||
}
|
||||
deltaChan <- &api.ProviderStreamDelta{
|
||||
Text: " chunk2",
|
||||
Usage: &api.Usage{
|
||||
InputTokens: 50,
|
||||
OutputTokens: 25,
|
||||
TotalTokens: 75,
|
||||
},
|
||||
}
|
||||
deltaChan <- &api.ProviderStreamDelta{
|
||||
Done: true,
|
||||
}
|
||||
}()
|
||||
|
||||
return deltaChan, errChan
|
||||
}
|
||||
|
||||
func (m *mockBaseProvider) getCallCount() int {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
return m.callCount
|
||||
}
|
||||
|
||||
func TestNewInstrumentedProvider(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
providerName string
|
||||
withRegistry bool
|
||||
withTracer bool
|
||||
}{
|
||||
{
|
||||
name: "with registry and tracer",
|
||||
providerName: "openai",
|
||||
withRegistry: true,
|
||||
withTracer: true,
|
||||
},
|
||||
{
|
||||
name: "with registry only",
|
||||
providerName: "anthropic",
|
||||
withRegistry: true,
|
||||
withTracer: false,
|
||||
},
|
||||
{
|
||||
name: "with tracer only",
|
||||
providerName: "google",
|
||||
withRegistry: false,
|
||||
withTracer: true,
|
||||
},
|
||||
{
|
||||
name: "without observability",
|
||||
providerName: "test",
|
||||
withRegistry: false,
|
||||
withTracer: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
base := newMockBaseProvider(tt.providerName)
|
||||
|
||||
var registry *prometheus.Registry
|
||||
if tt.withRegistry {
|
||||
registry = NewTestRegistry()
|
||||
}
|
||||
|
||||
var tp *sdktrace.TracerProvider
|
||||
_ = tp
|
||||
if tt.withTracer {
|
||||
tp, _ = NewTestTracer()
|
||||
defer ShutdownTracer(tp)
|
||||
}
|
||||
|
||||
wrapped := NewInstrumentedProvider(base, registry, tp)
|
||||
require.NotNil(t, wrapped)
|
||||
|
||||
instrumented, ok := wrapped.(*InstrumentedProvider)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, tt.providerName, instrumented.Name())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstrumentedProvider_Generate(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
setupMock func(*mockBaseProvider)
|
||||
expectError bool
|
||||
checkMetrics bool
|
||||
}{
|
||||
{
|
||||
name: "successful generation",
|
||||
setupMock: func(m *mockBaseProvider) {
|
||||
m.generateFunc = func(ctx context.Context, messages []api.Message, req *api.ResponseRequest) (*api.ProviderResult, error) {
|
||||
return &api.ProviderResult{
|
||||
ID: "success-id",
|
||||
Model: req.Model,
|
||||
Text: "Generated text",
|
||||
Usage: api.Usage{
|
||||
InputTokens: 200,
|
||||
OutputTokens: 100,
|
||||
TotalTokens: 300,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
},
|
||||
expectError: false,
|
||||
checkMetrics: true,
|
||||
},
|
||||
{
|
||||
name: "generation error",
|
||||
setupMock: func(m *mockBaseProvider) {
|
||||
m.generateFunc = func(ctx context.Context, messages []api.Message, req *api.ResponseRequest) (*api.ProviderResult, error) {
|
||||
return nil, errors.New("provider error")
|
||||
}
|
||||
},
|
||||
expectError: true,
|
||||
checkMetrics: true,
|
||||
},
|
||||
{
|
||||
name: "nil result",
|
||||
setupMock: func(m *mockBaseProvider) {
|
||||
m.generateFunc = func(ctx context.Context, messages []api.Message, req *api.ResponseRequest) (*api.ProviderResult, error) {
|
||||
return nil, nil
|
||||
}
|
||||
},
|
||||
expectError: false,
|
||||
checkMetrics: true,
|
||||
},
|
||||
{
|
||||
name: "empty tokens",
|
||||
setupMock: func(m *mockBaseProvider) {
|
||||
m.generateFunc = func(ctx context.Context, messages []api.Message, req *api.ResponseRequest) (*api.ProviderResult, error) {
|
||||
return &api.ProviderResult{
|
||||
ID: "zero-tokens",
|
||||
Model: req.Model,
|
||||
Text: "text",
|
||||
Usage: api.Usage{
|
||||
InputTokens: 0,
|
||||
OutputTokens: 0,
|
||||
TotalTokens: 0,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
},
|
||||
expectError: false,
|
||||
checkMetrics: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Reset metrics
|
||||
providerRequestsTotal.Reset()
|
||||
providerRequestDuration.Reset()
|
||||
providerTokensTotal.Reset()
|
||||
|
||||
base := newMockBaseProvider("test-provider")
|
||||
tt.setupMock(base)
|
||||
|
||||
registry := NewTestRegistry()
|
||||
InitMetrics() // Ensure metrics are registered
|
||||
|
||||
tp, exporter := NewTestTracer()
|
||||
defer ShutdownTracer(tp)
|
||||
|
||||
wrapped := NewInstrumentedProvider(base, registry, tp)
|
||||
|
||||
ctx := context.Background()
|
||||
messages := []api.Message{
|
||||
{Role: "user", Content: []api.ContentBlock{{Type: "text", Text: "test"}}},
|
||||
}
|
||||
req := &api.ResponseRequest{Model: "test-model"}
|
||||
|
||||
result, err := wrapped.Generate(ctx, messages, req)
|
||||
|
||||
if tt.expectError {
|
||||
assert.Error(t, err)
|
||||
assert.Nil(t, result)
|
||||
} else {
|
||||
if result != nil {
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, result)
|
||||
}
|
||||
}
|
||||
|
||||
// Verify provider was called
|
||||
assert.Equal(t, 1, base.getCallCount())
|
||||
|
||||
// Check metrics were recorded
|
||||
if tt.checkMetrics {
|
||||
status := "success"
|
||||
if tt.expectError {
|
||||
status = "error"
|
||||
}
|
||||
|
||||
counter := providerRequestsTotal.WithLabelValues("test-provider", "test-model", "generate", status)
|
||||
value := testutil.ToFloat64(counter)
|
||||
assert.Equal(t, 1.0, value, "request counter should be incremented")
|
||||
}
|
||||
|
||||
// Check spans were created
|
||||
spans := exporter.GetSpans()
|
||||
if len(spans) > 0 {
|
||||
span := spans[0]
|
||||
assert.Equal(t, "provider.generate", span.Name)
|
||||
|
||||
if tt.expectError {
|
||||
assert.Equal(t, codes.Error, span.Status.Code)
|
||||
} else if result != nil {
|
||||
assert.Equal(t, codes.Ok, span.Status.Code)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstrumentedProvider_GenerateStream(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
setupMock func(*mockBaseProvider)
|
||||
expectError bool
|
||||
checkMetrics bool
|
||||
expectedChunks int
|
||||
}{
|
||||
{
|
||||
name: "successful streaming",
|
||||
setupMock: func(m *mockBaseProvider) {
|
||||
m.streamFunc = func(ctx context.Context, messages []api.Message, req *api.ResponseRequest) (<-chan *api.ProviderStreamDelta, <-chan error) {
|
||||
deltaChan := make(chan *api.ProviderStreamDelta, 4)
|
||||
errChan := make(chan error, 1)
|
||||
|
||||
go func() {
|
||||
defer close(deltaChan)
|
||||
defer close(errChan)
|
||||
|
||||
deltaChan <- &api.ProviderStreamDelta{
|
||||
Model: req.Model,
|
||||
Text: "First ",
|
||||
}
|
||||
deltaChan <- &api.ProviderStreamDelta{
|
||||
Text: "Second ",
|
||||
}
|
||||
deltaChan <- &api.ProviderStreamDelta{
|
||||
Text: "Third",
|
||||
Usage: &api.Usage{
|
||||
InputTokens: 150,
|
||||
OutputTokens: 75,
|
||||
TotalTokens: 225,
|
||||
},
|
||||
}
|
||||
deltaChan <- &api.ProviderStreamDelta{
|
||||
Done: true,
|
||||
}
|
||||
}()
|
||||
|
||||
return deltaChan, errChan
|
||||
}
|
||||
},
|
||||
expectError: false,
|
||||
checkMetrics: true,
|
||||
expectedChunks: 4,
|
||||
},
|
||||
{
|
||||
name: "streaming error",
|
||||
setupMock: func(m *mockBaseProvider) {
|
||||
m.streamFunc = func(ctx context.Context, messages []api.Message, req *api.ResponseRequest) (<-chan *api.ProviderStreamDelta, <-chan error) {
|
||||
deltaChan := make(chan *api.ProviderStreamDelta)
|
||||
errChan := make(chan error, 1)
|
||||
|
||||
go func() {
|
||||
defer close(deltaChan)
|
||||
defer close(errChan)
|
||||
|
||||
errChan <- errors.New("stream error")
|
||||
}()
|
||||
|
||||
return deltaChan, errChan
|
||||
}
|
||||
},
|
||||
expectError: true,
|
||||
checkMetrics: true,
|
||||
expectedChunks: 0,
|
||||
},
|
||||
{
|
||||
name: "empty stream",
|
||||
setupMock: func(m *mockBaseProvider) {
|
||||
m.streamFunc = func(ctx context.Context, messages []api.Message, req *api.ResponseRequest) (<-chan *api.ProviderStreamDelta, <-chan error) {
|
||||
deltaChan := make(chan *api.ProviderStreamDelta)
|
||||
errChan := make(chan error, 1)
|
||||
|
||||
go func() {
|
||||
defer close(deltaChan)
|
||||
defer close(errChan)
|
||||
}()
|
||||
|
||||
return deltaChan, errChan
|
||||
}
|
||||
},
|
||||
expectError: false,
|
||||
checkMetrics: true,
|
||||
expectedChunks: 0,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Reset metrics
|
||||
providerRequestsTotal.Reset()
|
||||
providerStreamDuration.Reset()
|
||||
providerStreamChunks.Reset()
|
||||
providerStreamTTFB.Reset()
|
||||
providerTokensTotal.Reset()
|
||||
|
||||
base := newMockBaseProvider("stream-provider")
|
||||
tt.setupMock(base)
|
||||
|
||||
registry := NewTestRegistry()
|
||||
InitMetrics()
|
||||
|
||||
tp, exporter := NewTestTracer()
|
||||
defer ShutdownTracer(tp)
|
||||
|
||||
wrapped := NewInstrumentedProvider(base, registry, tp)
|
||||
|
||||
ctx := context.Background()
|
||||
messages := []api.Message{
|
||||
{Role: "user", Content: []api.ContentBlock{{Type: "text", Text: "stream test"}}},
|
||||
}
|
||||
req := &api.ResponseRequest{Model: "stream-model"}
|
||||
|
||||
deltaChan, errChan := wrapped.GenerateStream(ctx, messages, req)
|
||||
|
||||
// Consume the stream
|
||||
var chunks []*api.ProviderStreamDelta
|
||||
var streamErr error
|
||||
|
||||
for {
|
||||
select {
|
||||
case delta, ok := <-deltaChan:
|
||||
if !ok {
|
||||
goto Done
|
||||
}
|
||||
chunks = append(chunks, delta)
|
||||
case err, ok := <-errChan:
|
||||
if ok && err != nil {
|
||||
streamErr = err
|
||||
goto Done
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Done:
|
||||
if tt.expectError {
|
||||
assert.Error(t, streamErr)
|
||||
} else {
|
||||
assert.NoError(t, streamErr)
|
||||
}
|
||||
|
||||
assert.Equal(t, tt.expectedChunks, len(chunks))
|
||||
|
||||
// Give goroutine time to finish metrics recording
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
// Verify provider was called
|
||||
assert.Equal(t, 1, base.getCallCount())
|
||||
|
||||
// Check metrics
|
||||
if tt.checkMetrics {
|
||||
status := "success"
|
||||
if tt.expectError {
|
||||
status = "error"
|
||||
}
|
||||
|
||||
counter := providerRequestsTotal.WithLabelValues("stream-provider", "stream-model", "generate_stream", status)
|
||||
value := testutil.ToFloat64(counter)
|
||||
assert.Equal(t, 1.0, value, "stream request counter should be incremented")
|
||||
}
|
||||
|
||||
// Check spans
|
||||
time.Sleep(100 * time.Millisecond) // Give time for span to be exported
|
||||
spans := exporter.GetSpans()
|
||||
if len(spans) > 0 {
|
||||
span := spans[0]
|
||||
assert.Equal(t, "provider.generate_stream", span.Name)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstrumentedProvider_MetricsRecording(t *testing.T) {
|
||||
// Reset all metrics
|
||||
providerRequestsTotal.Reset()
|
||||
providerRequestDuration.Reset()
|
||||
providerTokensTotal.Reset()
|
||||
providerStreamTTFB.Reset()
|
||||
providerStreamChunks.Reset()
|
||||
providerStreamDuration.Reset()
|
||||
|
||||
base := newMockBaseProvider("metrics-test")
|
||||
registry := NewTestRegistry()
|
||||
InitMetrics()
|
||||
|
||||
wrapped := NewInstrumentedProvider(base, registry, nil)
|
||||
|
||||
ctx := context.Background()
|
||||
messages := []api.Message{
|
||||
{Role: "user", Content: []api.ContentBlock{{Type: "text", Text: "test"}}},
|
||||
}
|
||||
req := &api.ResponseRequest{Model: "test-model"}
|
||||
|
||||
// Test Generate metrics
|
||||
result, err := wrapped.Generate(ctx, messages, req)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
|
||||
// Verify counter
|
||||
counter := providerRequestsTotal.WithLabelValues("metrics-test", "test-model", "generate", "success")
|
||||
value := testutil.ToFloat64(counter)
|
||||
assert.Equal(t, 1.0, value)
|
||||
|
||||
// Verify token metrics
|
||||
inputTokens := providerTokensTotal.WithLabelValues("metrics-test", "test-model", "input")
|
||||
inputValue := testutil.ToFloat64(inputTokens)
|
||||
assert.Equal(t, 100.0, inputValue)
|
||||
|
||||
outputTokens := providerTokensTotal.WithLabelValues("metrics-test", "test-model", "output")
|
||||
outputValue := testutil.ToFloat64(outputTokens)
|
||||
assert.Equal(t, 50.0, outputValue)
|
||||
}
|
||||
|
||||
func TestInstrumentedProvider_TracingSpans(t *testing.T) {
|
||||
base := newMockBaseProvider("trace-test")
|
||||
tp, exporter := NewTestTracer()
|
||||
defer ShutdownTracer(tp)
|
||||
|
||||
wrapped := NewInstrumentedProvider(base, nil, tp)
|
||||
|
||||
ctx := context.Background()
|
||||
messages := []api.Message{
|
||||
{Role: "user", Content: []api.ContentBlock{{Type: "text", Text: "trace"}}},
|
||||
}
|
||||
req := &api.ResponseRequest{Model: "trace-model"}
|
||||
|
||||
// Test Generate span
|
||||
result, err := wrapped.Generate(ctx, messages, req)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
|
||||
// Force span export
|
||||
tp.ForceFlush(ctx)
|
||||
|
||||
spans := exporter.GetSpans()
|
||||
require.GreaterOrEqual(t, len(spans), 1)
|
||||
|
||||
span := spans[0]
|
||||
assert.Equal(t, "provider.generate", span.Name)
|
||||
|
||||
// Check attributes
|
||||
attrs := span.Attributes
|
||||
attrMap := make(map[string]interface{})
|
||||
for _, attr := range attrs {
|
||||
attrMap[string(attr.Key)] = attr.Value.AsInterface()
|
||||
}
|
||||
|
||||
assert.Equal(t, "trace-test", attrMap["provider.name"])
|
||||
assert.Equal(t, "trace-model", attrMap["provider.model"])
|
||||
assert.Equal(t, int64(100), attrMap["provider.input_tokens"])
|
||||
assert.Equal(t, int64(50), attrMap["provider.output_tokens"])
|
||||
assert.Equal(t, int64(150), attrMap["provider.total_tokens"])
|
||||
}
|
||||
|
||||
func TestInstrumentedProvider_WithoutObservability(t *testing.T) {
|
||||
base := newMockBaseProvider("no-obs")
|
||||
wrapped := NewInstrumentedProvider(base, nil, nil)
|
||||
|
||||
ctx := context.Background()
|
||||
messages := []api.Message{
|
||||
{Role: "user", Content: []api.ContentBlock{{Type: "text", Text: "test"}}},
|
||||
}
|
||||
req := &api.ResponseRequest{Model: "test"}
|
||||
|
||||
// Should work without observability
|
||||
result, err := wrapped.Generate(ctx, messages, req)
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, result)
|
||||
|
||||
// Stream should also work
|
||||
deltaChan, errChan := wrapped.GenerateStream(ctx, messages, req)
|
||||
|
||||
for {
|
||||
select {
|
||||
case _, ok := <-deltaChan:
|
||||
if !ok {
|
||||
goto Done
|
||||
}
|
||||
case <-errChan:
|
||||
goto Done
|
||||
}
|
||||
}
|
||||
|
||||
Done:
|
||||
assert.Equal(t, 2, base.getCallCount())
|
||||
}
|
||||
|
||||
func TestInstrumentedProvider_Name(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
providerName string
|
||||
}{
|
||||
{
|
||||
name: "openai provider",
|
||||
providerName: "openai",
|
||||
},
|
||||
{
|
||||
name: "anthropic provider",
|
||||
providerName: "anthropic",
|
||||
},
|
||||
{
|
||||
name: "google provider",
|
||||
providerName: "google",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
base := newMockBaseProvider(tt.providerName)
|
||||
wrapped := NewInstrumentedProvider(base, nil, nil)
|
||||
|
||||
assert.Equal(t, tt.providerName, wrapped.Name())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstrumentedProvider_ConcurrentCalls(t *testing.T) {
|
||||
base := newMockBaseProvider("concurrent-test")
|
||||
registry := NewTestRegistry()
|
||||
InitMetrics()
|
||||
|
||||
tp, _ := NewTestTracer()
|
||||
defer ShutdownTracer(tp)
|
||||
|
||||
wrapped := NewInstrumentedProvider(base, registry, tp)
|
||||
|
||||
ctx := context.Background()
|
||||
messages := []api.Message{
|
||||
{Role: "user", Content: []api.ContentBlock{{Type: "text", Text: "concurrent"}}},
|
||||
}
|
||||
|
||||
// Make concurrent requests
|
||||
const numRequests = 10
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(numRequests)
|
||||
|
||||
for i := 0; i < numRequests; i++ {
|
||||
go func(idx int) {
|
||||
defer wg.Done()
|
||||
req := &api.ResponseRequest{Model: "concurrent-model"}
|
||||
_, _ = wrapped.Generate(ctx, messages, req)
|
||||
}(i)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
// Verify all calls were made
|
||||
assert.Equal(t, numRequests, base.getCallCount())
|
||||
|
||||
// Verify metrics recorded all requests
|
||||
counter := providerRequestsTotal.WithLabelValues("concurrent-test", "concurrent-model", "generate", "success")
|
||||
value := testutil.ToFloat64(counter)
|
||||
assert.Equal(t, float64(numRequests), value)
|
||||
}
|
||||
|
||||
func TestInstrumentedProvider_StreamTTFB(t *testing.T) {
|
||||
providerStreamTTFB.Reset()
|
||||
|
||||
base := newMockBaseProvider("ttfb-test")
|
||||
base.streamFunc = func(ctx context.Context, messages []api.Message, req *api.ResponseRequest) (<-chan *api.ProviderStreamDelta, <-chan error) {
|
||||
deltaChan := make(chan *api.ProviderStreamDelta, 2)
|
||||
errChan := make(chan error, 1)
|
||||
|
||||
go func() {
|
||||
defer close(deltaChan)
|
||||
defer close(errChan)
|
||||
|
||||
// Simulate delay before first chunk
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
deltaChan <- &api.ProviderStreamDelta{Text: "first"}
|
||||
deltaChan <- &api.ProviderStreamDelta{Done: true}
|
||||
}()
|
||||
|
||||
return deltaChan, errChan
|
||||
}
|
||||
|
||||
registry := NewTestRegistry()
|
||||
InitMetrics()
|
||||
wrapped := NewInstrumentedProvider(base, registry, nil)
|
||||
|
||||
ctx := context.Background()
|
||||
messages := []api.Message{
|
||||
{Role: "user", Content: []api.ContentBlock{{Type: "text", Text: "ttfb"}}},
|
||||
}
|
||||
req := &api.ResponseRequest{Model: "ttfb-model"}
|
||||
|
||||
deltaChan, errChan := wrapped.GenerateStream(ctx, messages, req)
|
||||
|
||||
// Consume stream
|
||||
for {
|
||||
select {
|
||||
case _, ok := <-deltaChan:
|
||||
if !ok {
|
||||
goto Done
|
||||
}
|
||||
case <-errChan:
|
||||
goto Done
|
||||
}
|
||||
}
|
||||
|
||||
Done:
|
||||
// Give time for metrics to be recorded
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
// TTFB should have been recorded (we can't check exact value due to timing)
|
||||
// Just verify the metric exists
|
||||
counter := providerStreamChunks.WithLabelValues("ttfb-test", "ttfb-model")
|
||||
value := testutil.ToFloat64(counter)
|
||||
assert.Greater(t, value, 0.0)
|
||||
}
|
||||
120
internal/observability/testing.go
Normal file
120
internal/observability/testing.go
Normal file
@@ -0,0 +1,120 @@
|
||||
package observability
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/prometheus/client_golang/prometheus/testutil"
|
||||
"go.opentelemetry.io/otel"
|
||||
"go.opentelemetry.io/otel/sdk/resource"
|
||||
sdktrace "go.opentelemetry.io/otel/sdk/trace"
|
||||
"go.opentelemetry.io/otel/sdk/trace/tracetest"
|
||||
semconv "go.opentelemetry.io/otel/semconv/v1.24.0"
|
||||
)
|
||||
|
||||
// NewTestRegistry creates a new isolated Prometheus registry for testing
|
||||
func NewTestRegistry() *prometheus.Registry {
|
||||
return prometheus.NewRegistry()
|
||||
}
|
||||
|
||||
// NewTestTracer creates a no-op tracer for testing
|
||||
func NewTestTracer() (*sdktrace.TracerProvider, *tracetest.InMemoryExporter) {
|
||||
exporter := tracetest.NewInMemoryExporter()
|
||||
res := resource.NewSchemaless(
|
||||
semconv.ServiceNameKey.String("test-service"),
|
||||
)
|
||||
tp := sdktrace.NewTracerProvider(
|
||||
sdktrace.WithSyncer(exporter),
|
||||
sdktrace.WithResource(res),
|
||||
)
|
||||
otel.SetTracerProvider(tp)
|
||||
return tp, exporter
|
||||
}
|
||||
|
||||
// GetMetricValue extracts a metric value from a registry
|
||||
func GetMetricValue(registry *prometheus.Registry, metricName string) (float64, error) {
|
||||
metrics, err := registry.Gather()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
for _, mf := range metrics {
|
||||
if mf.GetName() == metricName {
|
||||
if len(mf.GetMetric()) > 0 {
|
||||
m := mf.GetMetric()[0]
|
||||
if m.GetCounter() != nil {
|
||||
return m.GetCounter().GetValue(), nil
|
||||
}
|
||||
if m.GetGauge() != nil {
|
||||
return m.GetGauge().GetValue(), nil
|
||||
}
|
||||
if m.GetHistogram() != nil {
|
||||
return float64(m.GetHistogram().GetSampleCount()), nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
// CountMetricsWithName counts how many metrics match the given name
|
||||
func CountMetricsWithName(registry *prometheus.Registry, metricName string) (int, error) {
|
||||
metrics, err := registry.Gather()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
for _, mf := range metrics {
|
||||
if mf.GetName() == metricName {
|
||||
return len(mf.GetMetric()), nil
|
||||
}
|
||||
}
|
||||
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
// GetCounterValue is a helper to get counter values using testutil
|
||||
func GetCounterValue(counter prometheus.Counter) float64 {
|
||||
return testutil.ToFloat64(counter)
|
||||
}
|
||||
|
||||
// NewNoOpTracerProvider creates a tracer provider that discards all spans
|
||||
func NewNoOpTracerProvider() *sdktrace.TracerProvider {
|
||||
return sdktrace.NewTracerProvider(
|
||||
sdktrace.WithSpanProcessor(sdktrace.NewSimpleSpanProcessor(&noOpExporter{})),
|
||||
)
|
||||
}
|
||||
|
||||
// noOpExporter is an exporter that discards all spans
|
||||
type noOpExporter struct{}
|
||||
|
||||
func (e *noOpExporter) ExportSpans(context.Context, []sdktrace.ReadOnlySpan) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *noOpExporter) Shutdown(context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// ShutdownTracer is a helper to safely shutdown a tracer provider
|
||||
func ShutdownTracer(tp *sdktrace.TracerProvider) error {
|
||||
if tp != nil {
|
||||
return tp.Shutdown(context.Background())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// NewTestExporter creates a test exporter that writes to the provided writer
|
||||
type TestExporter struct {
|
||||
writer io.Writer
|
||||
}
|
||||
|
||||
func (e *TestExporter) ExportSpans(ctx context.Context, spans []sdktrace.ReadOnlySpan) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *TestExporter) Shutdown(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
@@ -17,19 +17,14 @@ import (
|
||||
// InitTracer initializes the OpenTelemetry tracer provider.
|
||||
func InitTracer(cfg config.TracingConfig) (*sdktrace.TracerProvider, error) {
|
||||
// Create resource with service information
|
||||
res, err := resource.Merge(
|
||||
resource.Default(),
|
||||
resource.NewWithAttributes(
|
||||
semconv.SchemaURL,
|
||||
// Use NewSchemaless to avoid schema version conflicts
|
||||
res := resource.NewSchemaless(
|
||||
semconv.ServiceName(cfg.ServiceName),
|
||||
),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create resource: %w", err)
|
||||
}
|
||||
|
||||
// Create exporter
|
||||
var exporter sdktrace.SpanExporter
|
||||
var err error
|
||||
switch cfg.Exporter.Type {
|
||||
case "otlp":
|
||||
exporter, err = createOTLPExporter(cfg.Exporter)
|
||||
|
||||
496
internal/observability/tracing_test.go
Normal file
496
internal/observability/tracing_test.go
Normal file
@@ -0,0 +1,496 @@
|
||||
package observability
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/ajac-zero/latticelm/internal/config"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
sdktrace "go.opentelemetry.io/otel/sdk/trace"
|
||||
)
|
||||
|
||||
func TestInitTracer_StdoutExporter(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
cfg config.TracingConfig
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
name: "stdout exporter with always sampler",
|
||||
cfg: config.TracingConfig{
|
||||
Enabled: true,
|
||||
ServiceName: "test-service",
|
||||
Sampler: config.SamplerConfig{
|
||||
Type: "always",
|
||||
},
|
||||
Exporter: config.ExporterConfig{
|
||||
Type: "stdout",
|
||||
},
|
||||
},
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "stdout exporter with never sampler",
|
||||
cfg: config.TracingConfig{
|
||||
Enabled: true,
|
||||
ServiceName: "test-service-2",
|
||||
Sampler: config.SamplerConfig{
|
||||
Type: "never",
|
||||
},
|
||||
Exporter: config.ExporterConfig{
|
||||
Type: "stdout",
|
||||
},
|
||||
},
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "stdout exporter with probability sampler",
|
||||
cfg: config.TracingConfig{
|
||||
Enabled: true,
|
||||
ServiceName: "test-service-3",
|
||||
Sampler: config.SamplerConfig{
|
||||
Type: "probability",
|
||||
Rate: 0.5,
|
||||
},
|
||||
Exporter: config.ExporterConfig{
|
||||
Type: "stdout",
|
||||
},
|
||||
},
|
||||
expectError: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
tp, err := InitTracer(tt.cfg)
|
||||
|
||||
if tt.expectError {
|
||||
assert.Error(t, err)
|
||||
assert.Nil(t, tp)
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, tp)
|
||||
|
||||
// Clean up
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
err = tp.Shutdown(ctx)
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestInitTracer_InvalidExporter(t *testing.T) {
|
||||
cfg := config.TracingConfig{
|
||||
Enabled: true,
|
||||
ServiceName: "test-service",
|
||||
Sampler: config.SamplerConfig{
|
||||
Type: "always",
|
||||
},
|
||||
Exporter: config.ExporterConfig{
|
||||
Type: "invalid-exporter",
|
||||
},
|
||||
}
|
||||
|
||||
tp, err := InitTracer(cfg)
|
||||
assert.Error(t, err)
|
||||
assert.Nil(t, tp)
|
||||
assert.Contains(t, err.Error(), "unsupported exporter type")
|
||||
}
|
||||
|
||||
func TestCreateSampler(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
cfg config.SamplerConfig
|
||||
expectedType string
|
||||
shouldSample bool
|
||||
checkSampleAll bool // If true, check that all spans are sampled
|
||||
}{
|
||||
{
|
||||
name: "always sampler",
|
||||
cfg: config.SamplerConfig{
|
||||
Type: "always",
|
||||
},
|
||||
expectedType: "AlwaysOn",
|
||||
shouldSample: true,
|
||||
checkSampleAll: true,
|
||||
},
|
||||
{
|
||||
name: "never sampler",
|
||||
cfg: config.SamplerConfig{
|
||||
Type: "never",
|
||||
},
|
||||
expectedType: "AlwaysOff",
|
||||
shouldSample: false,
|
||||
checkSampleAll: true,
|
||||
},
|
||||
{
|
||||
name: "probability sampler - 100%",
|
||||
cfg: config.SamplerConfig{
|
||||
Type: "probability",
|
||||
Rate: 1.0,
|
||||
},
|
||||
expectedType: "AlwaysOn",
|
||||
shouldSample: true,
|
||||
checkSampleAll: true,
|
||||
},
|
||||
{
|
||||
name: "probability sampler - 0%",
|
||||
cfg: config.SamplerConfig{
|
||||
Type: "probability",
|
||||
Rate: 0.0,
|
||||
},
|
||||
expectedType: "TraceIDRatioBased",
|
||||
shouldSample: false,
|
||||
checkSampleAll: true,
|
||||
},
|
||||
{
|
||||
name: "probability sampler - 50%",
|
||||
cfg: config.SamplerConfig{
|
||||
Type: "probability",
|
||||
Rate: 0.5,
|
||||
},
|
||||
expectedType: "TraceIDRatioBased",
|
||||
shouldSample: false, // Can't guarantee sampling
|
||||
checkSampleAll: false,
|
||||
},
|
||||
{
|
||||
name: "default sampler (invalid type)",
|
||||
cfg: config.SamplerConfig{
|
||||
Type: "unknown",
|
||||
},
|
||||
expectedType: "TraceIDRatioBased",
|
||||
shouldSample: false, // 10% default
|
||||
checkSampleAll: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
sampler := createSampler(tt.cfg)
|
||||
require.NotNil(t, sampler)
|
||||
|
||||
// Get the sampler description
|
||||
description := sampler.Description()
|
||||
assert.Contains(t, description, tt.expectedType)
|
||||
|
||||
// Test sampling behavior for deterministic samplers
|
||||
if tt.checkSampleAll {
|
||||
tp := sdktrace.NewTracerProvider(
|
||||
sdktrace.WithSampler(sampler),
|
||||
)
|
||||
tracer := tp.Tracer("test")
|
||||
|
||||
// Create a test span
|
||||
ctx := context.Background()
|
||||
_, span := tracer.Start(ctx, "test-span")
|
||||
spanContext := span.SpanContext()
|
||||
span.End()
|
||||
|
||||
// Check if span was sampled
|
||||
isSampled := spanContext.IsSampled()
|
||||
assert.Equal(t, tt.shouldSample, isSampled, "sampling result should match expected")
|
||||
|
||||
// Clean up
|
||||
_ = tp.Shutdown(context.Background())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestShutdown(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
setupTP func() *sdktrace.TracerProvider
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
name: "shutdown valid tracer provider",
|
||||
setupTP: func() *sdktrace.TracerProvider {
|
||||
return sdktrace.NewTracerProvider()
|
||||
},
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "shutdown nil tracer provider",
|
||||
setupTP: func() *sdktrace.TracerProvider {
|
||||
return nil
|
||||
},
|
||||
expectError: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
tp := tt.setupTP()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
err := Shutdown(ctx, tp)
|
||||
|
||||
if tt.expectError {
|
||||
assert.Error(t, err)
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestShutdown_ContextTimeout(t *testing.T) {
|
||||
tp := sdktrace.NewTracerProvider()
|
||||
|
||||
// Create a context that's already canceled
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
|
||||
err := Shutdown(ctx, tp)
|
||||
// Shutdown should handle context cancellation gracefully
|
||||
// The error might be nil or context.Canceled depending on timing
|
||||
if err != nil {
|
||||
assert.Contains(t, err.Error(), "context")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTracerConfig_ServiceName(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
serviceName string
|
||||
}{
|
||||
{
|
||||
name: "default service name",
|
||||
serviceName: "llm-gateway",
|
||||
},
|
||||
{
|
||||
name: "custom service name",
|
||||
serviceName: "custom-gateway",
|
||||
},
|
||||
{
|
||||
name: "empty service name",
|
||||
serviceName: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
cfg := config.TracingConfig{
|
||||
Enabled: true,
|
||||
ServiceName: tt.serviceName,
|
||||
Sampler: config.SamplerConfig{
|
||||
Type: "always",
|
||||
},
|
||||
Exporter: config.ExporterConfig{
|
||||
Type: "stdout",
|
||||
},
|
||||
}
|
||||
|
||||
tp, err := InitTracer(cfg)
|
||||
// Schema URL conflicts may occur in test environment, which is acceptable
|
||||
if err != nil && !strings.Contains(err.Error(), "conflicting Schema URL") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if tp != nil {
|
||||
// Clean up
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
_ = tp.Shutdown(ctx)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateSampler_EdgeCases(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
cfg config.SamplerConfig
|
||||
}{
|
||||
{
|
||||
name: "negative rate",
|
||||
cfg: config.SamplerConfig{
|
||||
Type: "probability",
|
||||
Rate: -0.5,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "rate greater than 1",
|
||||
cfg: config.SamplerConfig{
|
||||
Type: "probability",
|
||||
Rate: 1.5,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "empty type",
|
||||
cfg: config.SamplerConfig{
|
||||
Type: "",
|
||||
Rate: 0.5,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// createSampler should not panic with edge cases
|
||||
sampler := createSampler(tt.cfg)
|
||||
assert.NotNil(t, sampler)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTracerProvider_MultipleShutdowns(t *testing.T) {
|
||||
tp := sdktrace.NewTracerProvider()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// First shutdown should succeed
|
||||
err1 := Shutdown(ctx, tp)
|
||||
assert.NoError(t, err1)
|
||||
|
||||
// Second shutdown might return error but shouldn't panic
|
||||
err2 := Shutdown(ctx, tp)
|
||||
// Error is acceptable here as provider is already shut down
|
||||
_ = err2
|
||||
}
|
||||
|
||||
func TestSamplerDescription(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
cfg config.SamplerConfig
|
||||
expectedInDesc string
|
||||
}{
|
||||
{
|
||||
name: "always sampler description",
|
||||
cfg: config.SamplerConfig{
|
||||
Type: "always",
|
||||
},
|
||||
expectedInDesc: "AlwaysOn",
|
||||
},
|
||||
{
|
||||
name: "never sampler description",
|
||||
cfg: config.SamplerConfig{
|
||||
Type: "never",
|
||||
},
|
||||
expectedInDesc: "AlwaysOff",
|
||||
},
|
||||
{
|
||||
name: "probability sampler description",
|
||||
cfg: config.SamplerConfig{
|
||||
Type: "probability",
|
||||
Rate: 0.75,
|
||||
},
|
||||
expectedInDesc: "TraceIDRatioBased",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
sampler := createSampler(tt.cfg)
|
||||
description := sampler.Description()
|
||||
assert.Contains(t, description, tt.expectedInDesc)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestInitTracer_ResourceAttributes(t *testing.T) {
|
||||
cfg := config.TracingConfig{
|
||||
Enabled: true,
|
||||
ServiceName: "test-resource-service",
|
||||
Sampler: config.SamplerConfig{
|
||||
Type: "always",
|
||||
},
|
||||
Exporter: config.ExporterConfig{
|
||||
Type: "stdout",
|
||||
},
|
||||
}
|
||||
|
||||
tp, err := InitTracer(cfg)
|
||||
// Schema URL conflicts may occur in test environment, which is acceptable
|
||||
if err != nil && !strings.Contains(err.Error(), "conflicting Schema URL") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if tp != nil {
|
||||
// Verify that the tracer provider was created successfully
|
||||
// Resource attributes are embedded in the provider
|
||||
tracer := tp.Tracer("test")
|
||||
assert.NotNil(t, tracer)
|
||||
|
||||
// Clean up
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
_ = tp.Shutdown(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProbabilitySampler_Boundaries(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
rate float64
|
||||
shouldAlways bool
|
||||
shouldNever bool
|
||||
}{
|
||||
{
|
||||
name: "rate 0.0 - never sample",
|
||||
rate: 0.0,
|
||||
shouldAlways: false,
|
||||
shouldNever: true,
|
||||
},
|
||||
{
|
||||
name: "rate 1.0 - always sample",
|
||||
rate: 1.0,
|
||||
shouldAlways: true,
|
||||
shouldNever: false,
|
||||
},
|
||||
{
|
||||
name: "rate 0.5 - probabilistic",
|
||||
rate: 0.5,
|
||||
shouldAlways: false,
|
||||
shouldNever: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
cfg := config.SamplerConfig{
|
||||
Type: "probability",
|
||||
Rate: tt.rate,
|
||||
}
|
||||
|
||||
sampler := createSampler(cfg)
|
||||
tp := sdktrace.NewTracerProvider(
|
||||
sdktrace.WithSampler(sampler),
|
||||
)
|
||||
defer tp.Shutdown(context.Background())
|
||||
|
||||
tracer := tp.Tracer("test")
|
||||
|
||||
// Test multiple spans to verify sampling behavior
|
||||
sampledCount := 0
|
||||
totalSpans := 100
|
||||
|
||||
for i := 0; i < totalSpans; i++ {
|
||||
ctx := context.Background()
|
||||
_, span := tracer.Start(ctx, "test-span")
|
||||
if span.SpanContext().IsSampled() {
|
||||
sampledCount++
|
||||
}
|
||||
span.End()
|
||||
}
|
||||
|
||||
if tt.shouldAlways {
|
||||
assert.Equal(t, totalSpans, sampledCount, "all spans should be sampled")
|
||||
} else if tt.shouldNever {
|
||||
assert.Equal(t, 0, sampledCount, "no spans should be sampled")
|
||||
} else {
|
||||
// For probabilistic sampling, we just verify it's not all or nothing
|
||||
assert.Greater(t, sampledCount, 0, "some spans should be sampled")
|
||||
assert.Less(t, sampledCount, totalSpans, "not all spans should be sampled")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
291
internal/providers/anthropic/anthropic_test.go
Normal file
291
internal/providers/anthropic/anthropic_test.go
Normal file
@@ -0,0 +1,291 @@
|
||||
package anthropic
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/ajac-zero/latticelm/internal/api"
|
||||
"github.com/ajac-zero/latticelm/internal/config"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestNew(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
cfg config.ProviderConfig
|
||||
validate func(t *testing.T, p *Provider)
|
||||
}{
|
||||
{
|
||||
name: "creates provider with API key",
|
||||
cfg: config.ProviderConfig{
|
||||
APIKey: "sk-ant-test-key",
|
||||
Model: "claude-3-opus",
|
||||
},
|
||||
validate: func(t *testing.T, p *Provider) {
|
||||
assert.NotNil(t, p)
|
||||
assert.NotNil(t, p.client)
|
||||
assert.Equal(t, "sk-ant-test-key", p.cfg.APIKey)
|
||||
assert.Equal(t, "claude-3-opus", p.cfg.Model)
|
||||
assert.False(t, p.azure)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "creates provider without API key",
|
||||
cfg: config.ProviderConfig{
|
||||
APIKey: "",
|
||||
},
|
||||
validate: func(t *testing.T, p *Provider) {
|
||||
assert.NotNil(t, p)
|
||||
assert.Nil(t, p.client)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
p := New(tt.cfg)
|
||||
tt.validate(t, p)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewAzure(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
cfg config.AzureAnthropicConfig
|
||||
validate func(t *testing.T, p *Provider)
|
||||
}{
|
||||
{
|
||||
name: "creates Azure provider with endpoint and API key",
|
||||
cfg: config.AzureAnthropicConfig{
|
||||
APIKey: "azure-key",
|
||||
Endpoint: "https://test.services.ai.azure.com/anthropic",
|
||||
Model: "claude-3-sonnet",
|
||||
},
|
||||
validate: func(t *testing.T, p *Provider) {
|
||||
assert.NotNil(t, p)
|
||||
assert.NotNil(t, p.client)
|
||||
assert.Equal(t, "azure-key", p.cfg.APIKey)
|
||||
assert.Equal(t, "claude-3-sonnet", p.cfg.Model)
|
||||
assert.True(t, p.azure)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "creates Azure provider without API key",
|
||||
cfg: config.AzureAnthropicConfig{
|
||||
APIKey: "",
|
||||
Endpoint: "https://test.services.ai.azure.com/anthropic",
|
||||
},
|
||||
validate: func(t *testing.T, p *Provider) {
|
||||
assert.NotNil(t, p)
|
||||
assert.Nil(t, p.client)
|
||||
assert.True(t, p.azure)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "creates Azure provider without endpoint",
|
||||
cfg: config.AzureAnthropicConfig{
|
||||
APIKey: "azure-key",
|
||||
Endpoint: "",
|
||||
},
|
||||
validate: func(t *testing.T, p *Provider) {
|
||||
assert.NotNil(t, p)
|
||||
assert.Nil(t, p.client)
|
||||
assert.True(t, p.azure)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
p := NewAzure(tt.cfg)
|
||||
tt.validate(t, p)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestProvider_Name(t *testing.T) {
|
||||
p := New(config.ProviderConfig{})
|
||||
assert.Equal(t, "anthropic", p.Name())
|
||||
}
|
||||
|
||||
func TestProvider_Generate_Validation(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
provider *Provider
|
||||
messages []api.Message
|
||||
req *api.ResponseRequest
|
||||
expectError bool
|
||||
errorMsg string
|
||||
}{
|
||||
{
|
||||
name: "returns error when API key missing",
|
||||
provider: &Provider{
|
||||
cfg: config.ProviderConfig{APIKey: ""},
|
||||
client: nil,
|
||||
},
|
||||
messages: []api.Message{
|
||||
{Role: "user", Content: []api.ContentBlock{{Type: "input_text", Text: "Hello"}}},
|
||||
},
|
||||
req: &api.ResponseRequest{
|
||||
Model: "claude-3-opus",
|
||||
},
|
||||
expectError: true,
|
||||
errorMsg: "api key missing",
|
||||
},
|
||||
{
|
||||
name: "returns error when client not initialized",
|
||||
provider: &Provider{
|
||||
cfg: config.ProviderConfig{APIKey: "sk-ant-test"},
|
||||
client: nil,
|
||||
},
|
||||
messages: []api.Message{
|
||||
{Role: "user", Content: []api.ContentBlock{{Type: "input_text", Text: "Hello"}}},
|
||||
},
|
||||
req: &api.ResponseRequest{
|
||||
Model: "claude-3-opus",
|
||||
},
|
||||
expectError: true,
|
||||
errorMsg: "client not initialized",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result, err := tt.provider.Generate(context.Background(), tt.messages, tt.req)
|
||||
|
||||
if tt.expectError {
|
||||
assert.Error(t, err)
|
||||
assert.Nil(t, result)
|
||||
if tt.errorMsg != "" {
|
||||
assert.Contains(t, err.Error(), tt.errorMsg)
|
||||
}
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestProvider_GenerateStream_Validation(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
provider *Provider
|
||||
messages []api.Message
|
||||
req *api.ResponseRequest
|
||||
expectError bool
|
||||
errorMsg string
|
||||
}{
|
||||
{
|
||||
name: "returns error when API key missing",
|
||||
provider: &Provider{
|
||||
cfg: config.ProviderConfig{APIKey: ""},
|
||||
client: nil,
|
||||
},
|
||||
messages: []api.Message{
|
||||
{Role: "user", Content: []api.ContentBlock{{Type: "input_text", Text: "Hello"}}},
|
||||
},
|
||||
req: &api.ResponseRequest{
|
||||
Model: "claude-3-opus",
|
||||
},
|
||||
expectError: true,
|
||||
errorMsg: "api key missing",
|
||||
},
|
||||
{
|
||||
name: "returns error when client not initialized",
|
||||
provider: &Provider{
|
||||
cfg: config.ProviderConfig{APIKey: "sk-ant-test"},
|
||||
client: nil,
|
||||
},
|
||||
messages: []api.Message{
|
||||
{Role: "user", Content: []api.ContentBlock{{Type: "input_text", Text: "Hello"}}},
|
||||
},
|
||||
req: &api.ResponseRequest{
|
||||
Model: "claude-3-opus",
|
||||
},
|
||||
expectError: true,
|
||||
errorMsg: "client not initialized",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
deltaChan, errChan := tt.provider.GenerateStream(context.Background(), tt.messages, tt.req)
|
||||
|
||||
// Read from channels
|
||||
var receivedError error
|
||||
for {
|
||||
select {
|
||||
case _, ok := <-deltaChan:
|
||||
if !ok {
|
||||
deltaChan = nil
|
||||
}
|
||||
case err, ok := <-errChan:
|
||||
if ok && err != nil {
|
||||
receivedError = err
|
||||
}
|
||||
errChan = nil
|
||||
}
|
||||
|
||||
if deltaChan == nil && errChan == nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if tt.expectError {
|
||||
require.Error(t, receivedError)
|
||||
if tt.errorMsg != "" {
|
||||
assert.Contains(t, receivedError.Error(), tt.errorMsg)
|
||||
}
|
||||
} else {
|
||||
assert.NoError(t, receivedError)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestChooseModel(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
requested string
|
||||
defaultModel string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "returns requested model when provided",
|
||||
requested: "claude-3-opus",
|
||||
defaultModel: "claude-3-sonnet",
|
||||
expected: "claude-3-opus",
|
||||
},
|
||||
{
|
||||
name: "returns default model when requested is empty",
|
||||
requested: "",
|
||||
defaultModel: "claude-3-sonnet",
|
||||
expected: "claude-3-sonnet",
|
||||
},
|
||||
{
|
||||
name: "returns fallback when both empty",
|
||||
requested: "",
|
||||
defaultModel: "",
|
||||
expected: "claude-3-5-sonnet",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := chooseModel(tt.requested, tt.defaultModel)
|
||||
assert.Equal(t, tt.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractToolCalls(t *testing.T) {
|
||||
// Note: This function is already tested in convert_test.go
|
||||
// This is a placeholder for additional integration tests if needed
|
||||
t.Run("returns nil for empty content", func(t *testing.T) {
|
||||
result := extractToolCalls(nil)
|
||||
assert.Nil(t, result)
|
||||
})
|
||||
}
|
||||
574
internal/providers/google/google_test.go
Normal file
574
internal/providers/google/google_test.go
Normal file
@@ -0,0 +1,574 @@
|
||||
package google
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/ajac-zero/latticelm/internal/api"
|
||||
"github.com/ajac-zero/latticelm/internal/config"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"google.golang.org/genai"
|
||||
)
|
||||
|
||||
func TestNew(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
cfg config.ProviderConfig
|
||||
expectError bool
|
||||
validate func(t *testing.T, p *Provider, err error)
|
||||
}{
|
||||
{
|
||||
name: "creates provider with API key",
|
||||
cfg: config.ProviderConfig{
|
||||
APIKey: "test-api-key",
|
||||
Model: "gemini-2.0-flash",
|
||||
},
|
||||
expectError: false,
|
||||
validate: func(t *testing.T, p *Provider, err error) {
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, p)
|
||||
assert.NotNil(t, p.client)
|
||||
assert.Equal(t, "test-api-key", p.cfg.APIKey)
|
||||
assert.Equal(t, "gemini-2.0-flash", p.cfg.Model)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "creates provider without API key",
|
||||
cfg: config.ProviderConfig{
|
||||
APIKey: "",
|
||||
},
|
||||
expectError: false,
|
||||
validate: func(t *testing.T, p *Provider, err error) {
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, p)
|
||||
assert.Nil(t, p.client)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
p, err := New(tt.cfg)
|
||||
tt.validate(t, p, err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewVertexAI(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
cfg config.VertexAIConfig
|
||||
expectError bool
|
||||
validate func(t *testing.T, p *Provider, err error)
|
||||
}{
|
||||
{
|
||||
name: "creates Vertex AI provider with project and location",
|
||||
cfg: config.VertexAIConfig{
|
||||
Project: "my-gcp-project",
|
||||
Location: "us-central1",
|
||||
},
|
||||
expectError: false,
|
||||
validate: func(t *testing.T, p *Provider, err error) {
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, p)
|
||||
// Client creation may fail without proper GCP credentials in test env
|
||||
// but provider should be created
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "creates Vertex AI provider without project",
|
||||
cfg: config.VertexAIConfig{
|
||||
Project: "",
|
||||
Location: "us-central1",
|
||||
},
|
||||
expectError: false,
|
||||
validate: func(t *testing.T, p *Provider, err error) {
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, p)
|
||||
assert.Nil(t, p.client)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "creates Vertex AI provider without location",
|
||||
cfg: config.VertexAIConfig{
|
||||
Project: "my-gcp-project",
|
||||
Location: "",
|
||||
},
|
||||
expectError: false,
|
||||
validate: func(t *testing.T, p *Provider, err error) {
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, p)
|
||||
assert.Nil(t, p.client)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
p, err := NewVertexAI(tt.cfg)
|
||||
tt.validate(t, p, err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestProvider_Name(t *testing.T) {
|
||||
p := &Provider{}
|
||||
assert.Equal(t, "google", p.Name())
|
||||
}
|
||||
|
||||
func TestProvider_Generate_Validation(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
provider *Provider
|
||||
messages []api.Message
|
||||
req *api.ResponseRequest
|
||||
expectError bool
|
||||
errorMsg string
|
||||
}{
|
||||
{
|
||||
name: "returns error when client not initialized",
|
||||
provider: &Provider{
|
||||
client: nil,
|
||||
},
|
||||
messages: []api.Message{
|
||||
{Role: "user", Content: []api.ContentBlock{{Type: "input_text", Text: "Hello"}}},
|
||||
},
|
||||
req: &api.ResponseRequest{
|
||||
Model: "gemini-2.0-flash",
|
||||
},
|
||||
expectError: true,
|
||||
errorMsg: "client not initialized",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result, err := tt.provider.Generate(context.Background(), tt.messages, tt.req)
|
||||
|
||||
if tt.expectError {
|
||||
assert.Error(t, err)
|
||||
assert.Nil(t, result)
|
||||
if tt.errorMsg != "" {
|
||||
assert.Contains(t, err.Error(), tt.errorMsg)
|
||||
}
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestProvider_GenerateStream_Validation(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
provider *Provider
|
||||
messages []api.Message
|
||||
req *api.ResponseRequest
|
||||
expectError bool
|
||||
errorMsg string
|
||||
}{
|
||||
{
|
||||
name: "returns error when client not initialized",
|
||||
provider: &Provider{
|
||||
client: nil,
|
||||
},
|
||||
messages: []api.Message{
|
||||
{Role: "user", Content: []api.ContentBlock{{Type: "input_text", Text: "Hello"}}},
|
||||
},
|
||||
req: &api.ResponseRequest{
|
||||
Model: "gemini-2.0-flash",
|
||||
},
|
||||
expectError: true,
|
||||
errorMsg: "client not initialized",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
deltaChan, errChan := tt.provider.GenerateStream(context.Background(), tt.messages, tt.req)
|
||||
|
||||
// Read from channels
|
||||
var receivedError error
|
||||
for {
|
||||
select {
|
||||
case _, ok := <-deltaChan:
|
||||
if !ok {
|
||||
deltaChan = nil
|
||||
}
|
||||
case err, ok := <-errChan:
|
||||
if ok && err != nil {
|
||||
receivedError = err
|
||||
}
|
||||
errChan = nil
|
||||
}
|
||||
|
||||
if deltaChan == nil && errChan == nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if tt.expectError {
|
||||
require.Error(t, receivedError)
|
||||
if tt.errorMsg != "" {
|
||||
assert.Contains(t, receivedError.Error(), tt.errorMsg)
|
||||
}
|
||||
} else {
|
||||
assert.NoError(t, receivedError)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertMessages(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
messages []api.Message
|
||||
expectedContents int
|
||||
expectedSystem string
|
||||
validate func(t *testing.T, contents []*genai.Content, systemText string)
|
||||
}{
|
||||
{
|
||||
name: "converts user message",
|
||||
messages: []api.Message{
|
||||
{
|
||||
Role: "user",
|
||||
Content: []api.ContentBlock{
|
||||
{Type: "input_text", Text: "Hello"},
|
||||
},
|
||||
},
|
||||
},
|
||||
expectedContents: 1,
|
||||
expectedSystem: "",
|
||||
validate: func(t *testing.T, contents []*genai.Content, systemText string) {
|
||||
require.Len(t, contents, 1)
|
||||
assert.Equal(t, "user", contents[0].Role)
|
||||
assert.Equal(t, "", systemText)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "extracts system message",
|
||||
messages: []api.Message{
|
||||
{
|
||||
Role: "system",
|
||||
Content: []api.ContentBlock{
|
||||
{Type: "input_text", Text: "You are a helpful assistant"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Role: "user",
|
||||
Content: []api.ContentBlock{
|
||||
{Type: "input_text", Text: "Hello"},
|
||||
},
|
||||
},
|
||||
},
|
||||
expectedContents: 1,
|
||||
expectedSystem: "You are a helpful assistant",
|
||||
validate: func(t *testing.T, contents []*genai.Content, systemText string) {
|
||||
require.Len(t, contents, 1)
|
||||
assert.Equal(t, "You are a helpful assistant", systemText)
|
||||
assert.Equal(t, "user", contents[0].Role)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "converts assistant message with tool calls",
|
||||
messages: []api.Message{
|
||||
{
|
||||
Role: "assistant",
|
||||
Content: []api.ContentBlock{
|
||||
{Type: "output_text", Text: "Let me check the weather"},
|
||||
},
|
||||
ToolCalls: []api.ToolCall{
|
||||
{
|
||||
ID: "call_123",
|
||||
Name: "get_weather",
|
||||
Arguments: `{"location": "SF"}`,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
expectedContents: 1,
|
||||
validate: func(t *testing.T, contents []*genai.Content, systemText string) {
|
||||
require.Len(t, contents, 1)
|
||||
assert.Equal(t, "model", contents[0].Role)
|
||||
// Should have text part and function call part
|
||||
assert.GreaterOrEqual(t, len(contents[0].Parts), 1)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "converts tool result message",
|
||||
messages: []api.Message{
|
||||
{
|
||||
Role: "assistant",
|
||||
ToolCalls: []api.ToolCall{
|
||||
{ID: "call_123", Name: "get_weather", Arguments: "{}"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Role: "tool",
|
||||
CallID: "call_123",
|
||||
Name: "get_weather",
|
||||
Content: []api.ContentBlock{
|
||||
{Type: "output_text", Text: `{"temp": 72}`},
|
||||
},
|
||||
},
|
||||
},
|
||||
expectedContents: 2,
|
||||
validate: func(t *testing.T, contents []*genai.Content, systemText string) {
|
||||
require.Len(t, contents, 2)
|
||||
// Tool result should be in user role
|
||||
assert.Equal(t, "user", contents[1].Role)
|
||||
require.Len(t, contents[1].Parts, 1)
|
||||
assert.NotNil(t, contents[1].Parts[0].FunctionResponse)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "handles developer message as system",
|
||||
messages: []api.Message{
|
||||
{
|
||||
Role: "developer",
|
||||
Content: []api.ContentBlock{
|
||||
{Type: "input_text", Text: "Developer instruction"},
|
||||
},
|
||||
},
|
||||
},
|
||||
expectedContents: 0,
|
||||
expectedSystem: "Developer instruction",
|
||||
validate: func(t *testing.T, contents []*genai.Content, systemText string) {
|
||||
assert.Len(t, contents, 0)
|
||||
assert.Equal(t, "Developer instruction", systemText)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
contents, systemText := convertMessages(tt.messages)
|
||||
assert.Len(t, contents, tt.expectedContents)
|
||||
assert.Equal(t, tt.expectedSystem, systemText)
|
||||
if tt.validate != nil {
|
||||
tt.validate(t, contents, systemText)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildConfig(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
systemText string
|
||||
req *api.ResponseRequest
|
||||
tools []*genai.Tool
|
||||
toolConfig *genai.ToolConfig
|
||||
expectNil bool
|
||||
validate func(t *testing.T, cfg *genai.GenerateContentConfig)
|
||||
}{
|
||||
{
|
||||
name: "returns nil when no config needed",
|
||||
systemText: "",
|
||||
req: &api.ResponseRequest{},
|
||||
tools: nil,
|
||||
toolConfig: nil,
|
||||
expectNil: true,
|
||||
},
|
||||
{
|
||||
name: "creates config with system text",
|
||||
systemText: "You are helpful",
|
||||
req: &api.ResponseRequest{},
|
||||
expectNil: false,
|
||||
validate: func(t *testing.T, cfg *genai.GenerateContentConfig) {
|
||||
require.NotNil(t, cfg)
|
||||
require.NotNil(t, cfg.SystemInstruction)
|
||||
assert.Len(t, cfg.SystemInstruction.Parts, 1)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "creates config with max tokens",
|
||||
systemText: "",
|
||||
req: &api.ResponseRequest{
|
||||
MaxOutputTokens: intPtr(1000),
|
||||
},
|
||||
expectNil: false,
|
||||
validate: func(t *testing.T, cfg *genai.GenerateContentConfig) {
|
||||
require.NotNil(t, cfg)
|
||||
assert.Equal(t, int32(1000), cfg.MaxOutputTokens)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "creates config with temperature",
|
||||
systemText: "",
|
||||
req: &api.ResponseRequest{
|
||||
Temperature: float64Ptr(0.7),
|
||||
},
|
||||
expectNil: false,
|
||||
validate: func(t *testing.T, cfg *genai.GenerateContentConfig) {
|
||||
require.NotNil(t, cfg)
|
||||
require.NotNil(t, cfg.Temperature)
|
||||
assert.Equal(t, float32(0.7), *cfg.Temperature)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "creates config with top_p",
|
||||
systemText: "",
|
||||
req: &api.ResponseRequest{
|
||||
TopP: float64Ptr(0.9),
|
||||
},
|
||||
expectNil: false,
|
||||
validate: func(t *testing.T, cfg *genai.GenerateContentConfig) {
|
||||
require.NotNil(t, cfg)
|
||||
require.NotNil(t, cfg.TopP)
|
||||
assert.Equal(t, float32(0.9), *cfg.TopP)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "creates config with tools",
|
||||
systemText: "",
|
||||
req: &api.ResponseRequest{},
|
||||
tools: []*genai.Tool{
|
||||
{
|
||||
FunctionDeclarations: []*genai.FunctionDeclaration{
|
||||
{Name: "get_weather"},
|
||||
},
|
||||
},
|
||||
},
|
||||
expectNil: false,
|
||||
validate: func(t *testing.T, cfg *genai.GenerateContentConfig) {
|
||||
require.NotNil(t, cfg)
|
||||
require.Len(t, cfg.Tools, 1)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
cfg := buildConfig(tt.systemText, tt.req, tt.tools, tt.toolConfig)
|
||||
if tt.expectNil {
|
||||
assert.Nil(t, cfg)
|
||||
} else {
|
||||
require.NotNil(t, cfg)
|
||||
if tt.validate != nil {
|
||||
tt.validate(t, cfg)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestChooseModel(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
requested string
|
||||
defaultModel string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "returns requested model when provided",
|
||||
requested: "gemini-1.5-pro",
|
||||
defaultModel: "gemini-2.0-flash",
|
||||
expected: "gemini-1.5-pro",
|
||||
},
|
||||
{
|
||||
name: "returns default model when requested is empty",
|
||||
requested: "",
|
||||
defaultModel: "gemini-2.0-flash",
|
||||
expected: "gemini-2.0-flash",
|
||||
},
|
||||
{
|
||||
name: "returns fallback when both empty",
|
||||
requested: "",
|
||||
defaultModel: "",
|
||||
expected: "gemini-2.0-flash-exp",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := chooseModel(tt.requested, tt.defaultModel)
|
||||
assert.Equal(t, tt.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractToolCallDelta(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
part *genai.Part
|
||||
index int
|
||||
expected *api.ToolCallDelta
|
||||
}{
|
||||
{
|
||||
name: "extracts tool call delta",
|
||||
part: &genai.Part{
|
||||
FunctionCall: &genai.FunctionCall{
|
||||
ID: "call_123",
|
||||
Name: "get_weather",
|
||||
Args: map[string]any{"location": "SF"},
|
||||
},
|
||||
},
|
||||
index: 0,
|
||||
expected: &api.ToolCallDelta{
|
||||
Index: 0,
|
||||
ID: "call_123",
|
||||
Name: "get_weather",
|
||||
Arguments: `{"location":"SF"}`,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "returns nil for nil part",
|
||||
part: nil,
|
||||
index: 0,
|
||||
expected: nil,
|
||||
},
|
||||
{
|
||||
name: "returns nil for part without function call",
|
||||
part: &genai.Part{Text: "Hello"},
|
||||
index: 0,
|
||||
expected: nil,
|
||||
},
|
||||
{
|
||||
name: "generates ID when not provided",
|
||||
part: &genai.Part{
|
||||
FunctionCall: &genai.FunctionCall{
|
||||
ID: "",
|
||||
Name: "get_time",
|
||||
Args: map[string]any{},
|
||||
},
|
||||
},
|
||||
index: 1,
|
||||
expected: &api.ToolCallDelta{
|
||||
Index: 1,
|
||||
Name: "get_time",
|
||||
Arguments: `{}`,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := extractToolCallDelta(tt.part, tt.index)
|
||||
if tt.expected == nil {
|
||||
assert.Nil(t, result)
|
||||
} else {
|
||||
require.NotNil(t, result)
|
||||
assert.Equal(t, tt.expected.Index, result.Index)
|
||||
assert.Equal(t, tt.expected.Name, result.Name)
|
||||
if tt.part != nil && tt.part.FunctionCall != nil && tt.part.FunctionCall.ID != "" {
|
||||
assert.Equal(t, tt.expected.ID, result.ID)
|
||||
} else if tt.expected.ID == "" {
|
||||
// Generated ID should start with "call_"
|
||||
assert.Contains(t, result.ID, "call_")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Helper functions
|
||||
func intPtr(i int) *int {
|
||||
return &i
|
||||
}
|
||||
|
||||
func float64Ptr(f float64) *float64 {
|
||||
return &f
|
||||
}
|
||||
304
internal/providers/openai/openai_test.go
Normal file
304
internal/providers/openai/openai_test.go
Normal file
@@ -0,0 +1,304 @@
|
||||
package openai
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/ajac-zero/latticelm/internal/api"
|
||||
"github.com/ajac-zero/latticelm/internal/config"
|
||||
"github.com/openai/openai-go/v3"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestNew(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
cfg config.ProviderConfig
|
||||
validate func(t *testing.T, p *Provider)
|
||||
}{
|
||||
{
|
||||
name: "creates provider with API key",
|
||||
cfg: config.ProviderConfig{
|
||||
APIKey: "sk-test-key",
|
||||
Model: "gpt-4o",
|
||||
},
|
||||
validate: func(t *testing.T, p *Provider) {
|
||||
assert.NotNil(t, p)
|
||||
assert.NotNil(t, p.client)
|
||||
assert.Equal(t, "sk-test-key", p.cfg.APIKey)
|
||||
assert.Equal(t, "gpt-4o", p.cfg.Model)
|
||||
assert.False(t, p.azure)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "creates provider without API key",
|
||||
cfg: config.ProviderConfig{
|
||||
APIKey: "",
|
||||
},
|
||||
validate: func(t *testing.T, p *Provider) {
|
||||
assert.NotNil(t, p)
|
||||
assert.Nil(t, p.client)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
p := New(tt.cfg)
|
||||
tt.validate(t, p)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewAzure(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
cfg config.AzureOpenAIConfig
|
||||
validate func(t *testing.T, p *Provider)
|
||||
}{
|
||||
{
|
||||
name: "creates Azure provider with endpoint and API key",
|
||||
cfg: config.AzureOpenAIConfig{
|
||||
APIKey: "azure-key",
|
||||
Endpoint: "https://test.openai.azure.com",
|
||||
APIVersion: "2024-02-15-preview",
|
||||
},
|
||||
validate: func(t *testing.T, p *Provider) {
|
||||
assert.NotNil(t, p)
|
||||
assert.NotNil(t, p.client)
|
||||
assert.Equal(t, "azure-key", p.cfg.APIKey)
|
||||
assert.True(t, p.azure)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "creates Azure provider with default API version",
|
||||
cfg: config.AzureOpenAIConfig{
|
||||
APIKey: "azure-key",
|
||||
Endpoint: "https://test.openai.azure.com",
|
||||
APIVersion: "",
|
||||
},
|
||||
validate: func(t *testing.T, p *Provider) {
|
||||
assert.NotNil(t, p)
|
||||
assert.NotNil(t, p.client)
|
||||
assert.True(t, p.azure)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "creates Azure provider without API key",
|
||||
cfg: config.AzureOpenAIConfig{
|
||||
APIKey: "",
|
||||
Endpoint: "https://test.openai.azure.com",
|
||||
},
|
||||
validate: func(t *testing.T, p *Provider) {
|
||||
assert.NotNil(t, p)
|
||||
assert.Nil(t, p.client)
|
||||
assert.True(t, p.azure)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "creates Azure provider without endpoint",
|
||||
cfg: config.AzureOpenAIConfig{
|
||||
APIKey: "azure-key",
|
||||
Endpoint: "",
|
||||
},
|
||||
validate: func(t *testing.T, p *Provider) {
|
||||
assert.NotNil(t, p)
|
||||
assert.Nil(t, p.client)
|
||||
assert.True(t, p.azure)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
p := NewAzure(tt.cfg)
|
||||
tt.validate(t, p)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestProvider_Name(t *testing.T) {
|
||||
p := New(config.ProviderConfig{})
|
||||
assert.Equal(t, "openai", p.Name())
|
||||
}
|
||||
|
||||
func TestProvider_Generate_Validation(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
provider *Provider
|
||||
messages []api.Message
|
||||
req *api.ResponseRequest
|
||||
expectError bool
|
||||
errorMsg string
|
||||
}{
|
||||
{
|
||||
name: "returns error when API key missing",
|
||||
provider: &Provider{
|
||||
cfg: config.ProviderConfig{APIKey: ""},
|
||||
client: nil,
|
||||
},
|
||||
messages: []api.Message{
|
||||
{Role: "user", Content: []api.ContentBlock{{Type: "input_text", Text: "Hello"}}},
|
||||
},
|
||||
req: &api.ResponseRequest{
|
||||
Model: "gpt-4o",
|
||||
},
|
||||
expectError: true,
|
||||
errorMsg: "api key missing",
|
||||
},
|
||||
{
|
||||
name: "returns error when client not initialized",
|
||||
provider: &Provider{
|
||||
cfg: config.ProviderConfig{APIKey: "sk-test"},
|
||||
client: nil,
|
||||
},
|
||||
messages: []api.Message{
|
||||
{Role: "user", Content: []api.ContentBlock{{Type: "input_text", Text: "Hello"}}},
|
||||
},
|
||||
req: &api.ResponseRequest{
|
||||
Model: "gpt-4o",
|
||||
},
|
||||
expectError: true,
|
||||
errorMsg: "client not initialized",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result, err := tt.provider.Generate(context.Background(), tt.messages, tt.req)
|
||||
|
||||
if tt.expectError {
|
||||
assert.Error(t, err)
|
||||
assert.Nil(t, result)
|
||||
if tt.errorMsg != "" {
|
||||
assert.Contains(t, err.Error(), tt.errorMsg)
|
||||
}
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestProvider_GenerateStream_Validation(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
provider *Provider
|
||||
messages []api.Message
|
||||
req *api.ResponseRequest
|
||||
expectError bool
|
||||
errorMsg string
|
||||
}{
|
||||
{
|
||||
name: "returns error when API key missing",
|
||||
provider: &Provider{
|
||||
cfg: config.ProviderConfig{APIKey: ""},
|
||||
client: nil,
|
||||
},
|
||||
messages: []api.Message{
|
||||
{Role: "user", Content: []api.ContentBlock{{Type: "input_text", Text: "Hello"}}},
|
||||
},
|
||||
req: &api.ResponseRequest{
|
||||
Model: "gpt-4o",
|
||||
},
|
||||
expectError: true,
|
||||
errorMsg: "api key missing",
|
||||
},
|
||||
{
|
||||
name: "returns error when client not initialized",
|
||||
provider: &Provider{
|
||||
cfg: config.ProviderConfig{APIKey: "sk-test"},
|
||||
client: nil,
|
||||
},
|
||||
messages: []api.Message{
|
||||
{Role: "user", Content: []api.ContentBlock{{Type: "input_text", Text: "Hello"}}},
|
||||
},
|
||||
req: &api.ResponseRequest{
|
||||
Model: "gpt-4o",
|
||||
},
|
||||
expectError: true,
|
||||
errorMsg: "client not initialized",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
deltaChan, errChan := tt.provider.GenerateStream(context.Background(), tt.messages, tt.req)
|
||||
|
||||
// Read from channels
|
||||
var receivedError error
|
||||
for {
|
||||
select {
|
||||
case _, ok := <-deltaChan:
|
||||
if !ok {
|
||||
deltaChan = nil
|
||||
}
|
||||
case err, ok := <-errChan:
|
||||
if ok && err != nil {
|
||||
receivedError = err
|
||||
}
|
||||
errChan = nil
|
||||
}
|
||||
|
||||
if deltaChan == nil && errChan == nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if tt.expectError {
|
||||
require.Error(t, receivedError)
|
||||
if tt.errorMsg != "" {
|
||||
assert.Contains(t, receivedError.Error(), tt.errorMsg)
|
||||
}
|
||||
} else {
|
||||
assert.NoError(t, receivedError)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestChooseModel(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
requested string
|
||||
defaultModel string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "returns requested model when provided",
|
||||
requested: "gpt-4o",
|
||||
defaultModel: "gpt-4o-mini",
|
||||
expected: "gpt-4o",
|
||||
},
|
||||
{
|
||||
name: "returns default model when requested is empty",
|
||||
requested: "",
|
||||
defaultModel: "gpt-4o-mini",
|
||||
expected: "gpt-4o-mini",
|
||||
},
|
||||
{
|
||||
name: "returns fallback when both empty",
|
||||
requested: "",
|
||||
defaultModel: "",
|
||||
expected: "gpt-4o-mini",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := chooseModel(tt.requested, tt.defaultModel)
|
||||
assert.Equal(t, tt.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractToolCalls_Integration(t *testing.T) {
|
||||
// Additional integration tests for extractToolCalls beyond convert_test.go
|
||||
t.Run("handles empty message", func(t *testing.T) {
|
||||
msg := openai.ChatCompletionMessage{}
|
||||
result := extractToolCalls(msg)
|
||||
assert.Nil(t, result)
|
||||
})
|
||||
}
|
||||
@@ -1,98 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Test script to verify security fixes are working
|
||||
# Usage: ./test_security_fixes.sh [server_url]
|
||||
|
||||
SERVER_URL="${1:-http://localhost:8080}"
|
||||
GREEN='\033[0;32m'
|
||||
RED='\033[0;31m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
echo "Testing security improvements on $SERVER_URL"
|
||||
echo "================================================"
|
||||
echo ""
|
||||
|
||||
# Test 1: Request size limit
|
||||
echo -e "${YELLOW}Test 1: Request Size Limit${NC}"
|
||||
echo "Sending a request with 11MB payload (exceeds 10MB limit)..."
|
||||
|
||||
# Generate large payload
|
||||
LARGE_PAYLOAD=$(python3 -c "import json; print(json.dumps({'model': 'test', 'input': 'x' * 11000000}))" 2>/dev/null || \
|
||||
perl -e 'print "{\"model\":\"test\",\"input\":\"" . ("x" x 11000000) . "\"}"')
|
||||
|
||||
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$SERVER_URL/v1/responses" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$LARGE_PAYLOAD" \
|
||||
--max-time 5 2>/dev/null)
|
||||
|
||||
if [ "$HTTP_CODE" = "413" ]; then
|
||||
echo -e "${GREEN}✓ PASS: Received HTTP 413 (Request Entity Too Large)${NC}"
|
||||
else
|
||||
echo -e "${RED}✗ FAIL: Expected 413, got $HTTP_CODE${NC}"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# Test 2: Normal request size
|
||||
echo -e "${YELLOW}Test 2: Normal Request Size${NC}"
|
||||
echo "Sending a small valid request..."
|
||||
|
||||
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$SERVER_URL/v1/responses" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"model":"test","input":"hello"}' \
|
||||
--max-time 5 2>/dev/null)
|
||||
|
||||
# Expected: either 400 (invalid model) or 502 (provider error), but NOT 413
|
||||
if [ "$HTTP_CODE" != "413" ]; then
|
||||
echo -e "${GREEN}✓ PASS: Request not rejected by size limit (HTTP $HTTP_CODE)${NC}"
|
||||
else
|
||||
echo -e "${RED}✗ FAIL: Small request incorrectly rejected with 413${NC}"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# Test 3: Health endpoint
|
||||
echo -e "${YELLOW}Test 3: Health Endpoint${NC}"
|
||||
echo "Checking /health endpoint..."
|
||||
|
||||
RESPONSE=$(curl -s -X GET "$SERVER_URL/health" --max-time 5 2>/dev/null)
|
||||
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" -X GET "$SERVER_URL/health" --max-time 5 2>/dev/null)
|
||||
|
||||
if [ "$HTTP_CODE" = "200" ] && echo "$RESPONSE" | grep -q "healthy"; then
|
||||
echo -e "${GREEN}✓ PASS: Health endpoint responding correctly${NC}"
|
||||
else
|
||||
echo -e "${RED}✗ FAIL: Health endpoint not responding correctly (HTTP $HTTP_CODE)${NC}"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# Test 4: Ready endpoint
|
||||
echo -e "${YELLOW}Test 4: Ready Endpoint${NC}"
|
||||
echo "Checking /ready endpoint..."
|
||||
|
||||
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" -X GET "$SERVER_URL/ready" --max-time 5 2>/dev/null)
|
||||
|
||||
if [ "$HTTP_CODE" = "200" ] || [ "$HTTP_CODE" = "503" ]; then
|
||||
echo -e "${GREEN}✓ PASS: Ready endpoint responding (HTTP $HTTP_CODE)${NC}"
|
||||
else
|
||||
echo -e "${RED}✗ FAIL: Ready endpoint not responding correctly (HTTP $HTTP_CODE)${NC}"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# Test 5: Models endpoint
|
||||
echo -e "${YELLOW}Test 5: Models Endpoint${NC}"
|
||||
echo "Checking /v1/models endpoint..."
|
||||
|
||||
RESPONSE=$(curl -s -X GET "$SERVER_URL/v1/models" --max-time 5 2>/dev/null)
|
||||
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" -X GET "$SERVER_URL/v1/models" --max-time 5 2>/dev/null)
|
||||
|
||||
if [ "$HTTP_CODE" = "200" ] && echo "$RESPONSE" | grep -q "object"; then
|
||||
echo -e "${GREEN}✓ PASS: Models endpoint responding correctly${NC}"
|
||||
else
|
||||
echo -e "${RED}✗ FAIL: Models endpoint not responding correctly (HTTP $HTTP_CODE)${NC}"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
echo "================================================"
|
||||
echo -e "${GREEN}Testing complete!${NC}"
|
||||
echo ""
|
||||
echo "Note: Panic recovery cannot be tested externally without"
|
||||
echo "causing intentional server errors. It has been verified"
|
||||
echo "through unit tests in middleware_test.go"
|
||||
Reference in New Issue
Block a user