route cleanup

working

Working version
This commit is contained in:
Seif Ghazi 2025-08-03 22:30:13 -04:00
parent 1e0173c768
commit 4675fee4a3
No known key found for this signature in database
GPG key ID: 4519A4B1EEC1494E
22 changed files with 361 additions and 944 deletions

6
.gitignore vendored
View file

@ -38,4 +38,8 @@ coverage/
# Temporary files
tmp/
temp/
temp/
# Config
config.yaml

107
README.md
View file

@ -2,18 +2,20 @@
![Claude Code Proxy Demo](demo.gif)
A dual-purpose monitoring solution that serves as both a proxy for Claude Code requests and a visualization dashboard for your Claude API conversations.
A transparent proxy for Claude Code that monitors API requests, routes agents to different LLM providers, and provides a beautiful dashboard for analyzing conversations.
## What It Does
Claude Code Proxy serves two main purposes:
Claude Code Proxy serves three main purposes:
1. **Claude Code Proxy**: Intercepts and monitors requests from Claude Code (claude.ai/code) to the Anthropic API, allowing you to see what Claude Code is doing in real-time
2. **Conversation Viewer**: Displays and analyzes your Claude API conversations with a beautiful web interface
2. **Agent Routing (Optional)**: Routes specific Claude Code agents to different LLM providers (e.g., route code-reviewer agent to GPT-4o)
3. **Conversation Viewer**: Displays and analyzes your Claude API conversations with a beautiful web interface
## Features
- **Transparent Proxy**: Routes Claude Code requests through the monitor without disruption
- **Agent Routing (Optional)**: Map specific Claude Code agents to different LLM models
- **Request Monitoring**: SQLite-based logging of all API interactions
- **Live Dashboard**: Real-time visualization of requests and responses
- **Conversation Analysis**: View full conversation threads with tool usage
@ -34,9 +36,9 @@ Claude Code Proxy serves two main purposes:
cd claude-code-proxy
```
2. **Set up your environment variables**
2. **Configure the proxy**
```bash
cp .env.example .env
cp config.yaml.example config.yaml
```
3. **Install and run** (first time)
@ -44,11 +46,6 @@ Claude Code Proxy serves two main purposes:
make install # Install all dependencies
make dev # Start both services
```
Or use the script that does both:
```bash
./run.sh
```
4. **Subsequent runs** (after initial setup)
```bash
@ -100,14 +97,86 @@ make help # Show all commands
## Configuration
Create a `.env` file with:
```
PORT=3001
DB_PATH=requests.db
ANTHROPIC_FORWARD_URL=https://api.anthropic.com
### Basic Setup
Create a `config.yaml` file (or copy from `config.yaml.example`):
```yaml
server:
port: 3001
providers:
anthropic:
base_url: "https://api.anthropic.com"
openai: # if enabling subagent routing
api_key: "your-openai-key" # Or set OPENAI_API_KEY env var
storage:
db_path: "requests.db"
```
See `.env.example` for all available options.
### Subagent Configuration (Optional)
The proxy supports routing specific Claude Code agents to different LLM providers. This is an **optional** feature that's disabled by default.
#### Enabling Subagent Routing
1. **Enable the feature** in `config.yaml`:
```yaml
subagents:
enable: true # Set to true to enable subagent routing
mappings:
code-reviewer: "gpt-4o"
data-analyst: "o3"
doc-writer: "gpt-3.5-turbo"
```
2. **Set up your Claude Code agents** following Anthropic's official documentation:
- 📖 **[Claude Code Subagents Documentation](https://docs.anthropic.com/en/docs/claude-code/sub-agents)**
3. **How it works**: When Claude Code uses a subagent that matches one of your mappings, the proxy will automatically route the request to the specified model instead of Claude.
### Practical Examples
**Example 1: Code Review Agent → GPT-4o**
```yaml
# config.yaml
subagents:
enable: true
mappings:
code-reviewer: "gpt-4o"
```
Use case: Route code review tasks to GPT-4o for faster responses while keeping complex coding tasks on Claude.
**Example 2: Reasoning Agent → O3**
```yaml
# config.yaml
subagents:
enable: true
mappings:
deep-reasoning: "o3"
```
Use case: Send complex reasoning tasks to O3 while using Claude for general coding.
**Example 3: Multiple Agents**
```yaml
# config.yaml
subagents:
enable: true
mappings:
streaming-systems-engineer: "o3"
frontend-developer: "gpt-4o-mini"
security-auditor: "gpt-4o"
```
Use case: Different specialists for different tasks, optimizing for speed/cost/quality.
### Environment Variables
Override config via environment:
- `PORT` - Server port
- `OPENAI_API_KEY` - OpenAI API key
- `DB_PATH` - Database path
- `SUBAGENT_MAPPINGS` - Comma-separated mappings (e.g., `"code-reviewer:gpt-4o,data-analyst:o3"`)
## Project Structure
@ -134,12 +203,6 @@ claude-code-proxy/
- Request/response body inspection
- Conversation threading
### Prompt Analysis
- Automatic prompt grading
- Best practices evaluation
- Complexity assessment
- Response quality metrics
### Web Dashboard
- Real-time request streaming
- Interactive request explorer

View file

@ -21,17 +21,23 @@ providers:
openai:
# API key can be set here or via OPENAI_API_KEY environment variable
# api_key: "your-api-key-here"
base_url: "https://proxy-shopify-ai.local.shop.dev"
# base_url: ""
# Storage configuration
storage:
# SQLite database path for storing request history
db_path: "requests.db"
# Subagent mappings
# Maps subagent types to specific models
# Subagent Configuration (Optional)
# Enable this feature if you want to route specific Claude Code agents to different LLM providers
# For subaget setup instructions, see: https://docs.anthropic.com/en/docs/claude-code/sub-agents
subagents:
# Enable subagent routing
enable: false
# Maps subagent types to specific models
mappings:
streaming-systems-engineer: "gpt-4o"
codebase-analyzer: "gpt-4o"
# Add more subagent mappings as needed
# example-agent: "gpt-4o"

View file

@ -25,9 +25,6 @@ providers:
# Base URL for Anthropic API (can be changed for custom endpoints)
base_url: "https://api.anthropic.com"
# API version to use
version: "2023-06-01"
# Maximum number of retries for failed requests
max_retries: 3
@ -35,7 +32,7 @@ providers:
openai:
# API key for OpenAI
# Can also be set via OPENAI_API_KEY environment variable
# api_key: "sk-..."
# api_key: "..."
# Base URL for OpenAI API (can be changed for custom endpoints)
# Can also be set via OPENAI_BASE_URL environment variable
@ -49,15 +46,21 @@ storage:
# Directory for storing request files (if needed in future)
# requests_dir: "./requests"
# Subagent mappings
# Maps subagent types to specific models
# Subagent Configuration (Optional)
# Enable this feature if you want to route specific Claude Code agents to different LLM providers
# For subagent setup instructions, see: https://docs.anthropic.com/en/docs/claude-code/sub-agents
subagents:
# Enable subagent routing (default: false)
enable: false
# Maps subagent types to specific models
# Only used when enable: true
mappings:
# Code review specialist (example)
# code-reviewer: "gpt-4o"
# Data analysis expert (example)
# data-analyst: "claude-3-5-sonnet-20241022"
# data-analyst: "o3"
# Documentation writer (example)
# doc-writer: "gpt-3.5-turbo"
@ -78,6 +81,7 @@ subagents:
#
# OpenAI:
# OPENAI_API_KEY - OpenAI API key
# OPENAI_BASE_URL - OpenAI base URL
#
# Storage:
# DB_PATH - Database file path

View file

@ -83,16 +83,12 @@ func main() {
go func() {
logger.Printf("🚀 Claude Code Monitor Server running on http://localhost:%s", cfg.Server.Port)
logger.Printf("📡 API endpoints available at:")
logger.Printf(" - POST http://localhost:%s/v1/chat/completions (OpenAI format)", cfg.Server.Port)
logger.Printf(" - POST http://localhost:%s/v1/messages (Anthropic format)", cfg.Server.Port)
logger.Printf(" - GET http://localhost:%s/v1/models", cfg.Server.Port)
logger.Printf(" - GET http://localhost:%s/health", cfg.Server.Port)
logger.Printf(" - POST http://localhost:%s/api/grade-prompt (Prompt grading)", cfg.Server.Port)
logger.Printf("🎨 Web UI available at:")
logger.Printf(" - GET http://localhost:%s/ (Request Visualizer)", cfg.Server.Port)
logger.Printf(" - GET http://localhost:%s/api/requests (Request API)", cfg.Server.Port)
logger.Printf("🔍 All requests logged with comprehensive error handling")
logger.Printf("🎯 Auto prompt grading with Anthropic best practices")
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
logger.Fatalf("❌ Server failed to start: %v", err)

View file

@ -1,7 +1,6 @@
package config
import (
"fmt"
"os"
"path/filepath"
"strconv"
@ -16,7 +15,6 @@ type Config struct {
Providers ProvidersConfig `yaml:"providers"`
Storage StorageConfig `yaml:"storage"`
Subagents SubagentsConfig `yaml:"subagents"`
// Legacy fields for backward compatibility
Anthropic AnthropicConfig
}
@ -63,6 +61,7 @@ type StorageConfig struct {
}
type SubagentsConfig struct {
Enable bool `yaml:"enable"`
Mappings map[string]string `yaml:"mappings"`
}
@ -101,6 +100,7 @@ func Load() (*Config, error) {
DBPath: "requests.db",
},
Subagents: SubagentsConfig{
Enable: false,
Mappings: make(map[string]string),
},
}
@ -120,10 +120,7 @@ func Load() (*Config, error) {
}
}
if err := cfg.loadFromFile(configPath); err == nil {
fmt.Printf("Loaded config from %s\n", configPath)
fmt.Printf("Subagent mappings: %+v\n", cfg.Subagents.Mappings)
}
cfg.loadFromFile(configPath)
// Apply environment variable overrides AFTER loading from file
if envPort := os.Getenv("PORT"); envPort != "" {

View file

@ -1,202 +0,0 @@
package config
import (
"os"
"path/filepath"
"testing"
"time"
)
func TestLoad(t *testing.T) {
// Save original environment variables
originalConfigPath := os.Getenv("CONFIG_PATH")
originalPort := os.Getenv("PORT")
originalAnthropicURL := os.Getenv("ANTHROPIC_FORWARD_URL")
originalOpenAIKey := os.Getenv("OPENAI_API_KEY")
// Restore after test
defer func() {
os.Setenv("CONFIG_PATH", originalConfigPath)
os.Setenv("PORT", originalPort)
os.Setenv("ANTHROPIC_FORWARD_URL", originalAnthropicURL)
os.Setenv("OPENAI_API_KEY", originalOpenAIKey)
}()
t.Run("LoadWithValidConfigFile", func(t *testing.T) {
// Create a temporary config file
tempDir := t.TempDir()
configPath := filepath.Join(tempDir, "config.yaml")
configContent := `
server:
port: 8080
timeouts:
read: 5m
write: 5m
idle: 5m
providers:
anthropic:
base_url: "https://api.anthropic.com"
version: "2023-06-01"
max_retries: 3
openai:
base_url: "https://api.openai.com"
storage:
db_path: "test.db"
subagents:
mappings:
test-agent: "gpt-4"
`
err := os.WriteFile(configPath, []byte(configContent), 0644)
if err != nil {
t.Fatalf("Failed to write config file: %v", err)
}
// Set config path
os.Setenv("CONFIG_PATH", configPath)
// Load config
cfg, err := Load()
if err != nil {
t.Fatalf("Failed to load config: %v", err)
}
// Verify values
if cfg.Server.Port != "8080" {
t.Errorf("Expected port 8080, got %s", cfg.Server.Port)
}
if cfg.Anthropic.BaseURL != "https://api.anthropic.com" {
t.Errorf("Expected Anthropic URL https://api.anthropic.com, got %s", cfg.Anthropic.BaseURL)
}
if cfg.Storage.DBPath != "test.db" {
t.Errorf("Expected DB path test.db, got %s", cfg.Storage.DBPath)
}
if cfg.Subagents.Mappings["test-agent"] != "gpt-4" {
t.Errorf("Expected subagent mapping test-agent: gpt-4, got %s", cfg.Subagents.Mappings["test-agent"])
}
})
t.Run("LoadWithDefaults", func(t *testing.T) {
// Clear environment variables
os.Unsetenv("CONFIG_PATH")
os.Unsetenv("PORT")
// Create empty config directory
tempDir := t.TempDir()
os.Setenv("CONFIG_PATH", filepath.Join(tempDir, "nonexistent.yaml"))
// Load config (should use defaults)
cfg, err := Load()
if err != nil {
t.Fatalf("Failed to load config with defaults: %v", err)
}
// Verify default values
if cfg.Server.Port != "3001" {
t.Errorf("Expected default port 3001, got %s", cfg.Server.Port)
}
if cfg.Server.ReadTimeout != 10*time.Minute {
t.Errorf("Expected default read timeout 10m, got %v", cfg.Server.ReadTimeout)
}
if cfg.Anthropic.BaseURL != "https://api.anthropic.com" {
t.Errorf("Expected default Anthropic URL, got %s", cfg.Anthropic.BaseURL)
}
if cfg.Storage.DBPath != "requests.db" {
t.Errorf("Expected default DB path requests.db, got %s", cfg.Storage.DBPath)
}
})
t.Run("EnvironmentVariableOverrides", func(t *testing.T) {
// Create a config file
tempDir := t.TempDir()
configPath := filepath.Join(tempDir, "config.yaml")
configContent := `
server:
port: 8080
providers:
anthropic:
base_url: "https://api.anthropic.com"
openai:
api_key: "file-key"
`
err := os.WriteFile(configPath, []byte(configContent), 0644)
if err != nil {
t.Fatalf("Failed to write config file: %v", err)
}
// Set environment variables
os.Setenv("CONFIG_PATH", configPath)
os.Setenv("PORT", "9090")
os.Setenv("ANTHROPIC_FORWARD_URL", "https://custom.anthropic.com")
os.Setenv("OPENAI_API_KEY", "env-key")
// Load config
cfg, err := Load()
if err != nil {
t.Fatalf("Failed to load config: %v", err)
}
// Verify environment overrides
if cfg.Server.Port != "9090" {
t.Errorf("Expected port override 9090, got %s", cfg.Server.Port)
}
if cfg.Anthropic.BaseURL != "https://custom.anthropic.com" {
t.Errorf("Expected Anthropic URL override, got %s", cfg.Anthropic.BaseURL)
}
if cfg.OpenAI.APIKey != "env-key" {
t.Errorf("Expected OpenAI API key override, got %s", cfg.OpenAI.APIKey)
}
})
t.Run("InvalidYAML", func(t *testing.T) {
// Create invalid YAML file
tempDir := t.TempDir()
configPath := filepath.Join(tempDir, "invalid.yaml")
configContent := `
server:
port: [this is invalid
`
err := os.WriteFile(configPath, []byte(configContent), 0644)
if err != nil {
t.Fatalf("Failed to write config file: %v", err)
}
os.Setenv("CONFIG_PATH", configPath)
// Should still load with defaults (error is logged but not returned)
cfg, err := Load()
if err != nil {
t.Fatalf("Expected config to load with defaults despite invalid YAML: %v", err)
}
// Should have default values
if cfg.Server.Port != "3001" {
t.Errorf("Expected default port 3001 after invalid YAML, got %s", cfg.Server.Port)
}
})
}
func TestConfig_ParseTimeouts(t *testing.T) {
tests := []struct {
name string
timeoutStr string
expectedMinutes int
expectError bool
}{
{"Valid minutes", "5m", 5, false},
{"Valid seconds", "30s", 0, false}, // Will be 30 seconds, not minutes
{"Valid hours", "2h", 120, false},
{"Empty string", "", 10, false}, // Should use default
{"Invalid format", "invalid", 10, false}, // Should use default
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// This test would require exposing the parseTimeout function
// or testing it indirectly through the Load function
// For now, we'll skip the implementation details
})
}
}

View file

@ -3,7 +3,6 @@ package handler
import (
"bufio"
"bytes"
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
@ -44,22 +43,20 @@ func New(anthropicService service.AnthropicService, storageService service.Stora
}
func (h *Handler) ChatCompletions(w http.ResponseWriter, r *http.Request) {
log.Println("🤖 Chat completion request received (OpenAI format)")
// This endpoint is for compatibility but we're an Anthropic proxy
// Return a helpful error message
writeErrorResponse(w, "This is an Anthropic proxy. Please use the /v1/messages endpoint instead of /v1/chat/completions", http.StatusBadRequest)
}
func (h *Handler) Messages(w http.ResponseWriter, r *http.Request) {
log.Println("🤖 Messages request received (Anthropic format)")
// Get body bytes from context (set by middleware)
bodyBytes := getBodyBytes(r)
if bodyBytes == nil {
http.Error(w, "Error reading request body", http.StatusBadRequest)
return
}
// Parse the request
var req model.AnthropicRequest
if err := json.Unmarshal(bodyBytes, &req); err != nil {
log.Printf("❌ Error parsing JSON: %v", err)
@ -71,7 +68,7 @@ func (h *Handler) Messages(w http.ResponseWriter, r *http.Request) {
startTime := time.Now()
// Use model router to determine provider and route the request
provider, originalModel, err := h.modelRouter.RouteRequest(&req)
decision, err := h.modelRouter.DetermineRoute(&req)
if err != nil {
log.Printf("❌ Error routing request: %v", err)
writeErrorResponse(w, "Failed to route request", http.StatusInternalServerError)
@ -83,12 +80,12 @@ func (h *Handler) Messages(w http.ResponseWriter, r *http.Request) {
RequestID: requestID,
Timestamp: time.Now().Format(time.RFC3339),
Method: r.Method,
Endpoint: "/v1/messages",
Endpoint: r.URL.Path,
Headers: SanitizeHeaders(r.Header),
Body: req,
Model: req.Model,
OriginalModel: originalModel,
RoutedModel: req.Model,
Model: decision.OriginalModel,
OriginalModel: decision.OriginalModel,
RoutedModel: decision.TargetModel,
UserAgent: r.Header.Get("User-Agent"),
ContentType: r.Header.Get("Content-Type"),
}
@ -98,7 +95,9 @@ func (h *Handler) Messages(w http.ResponseWriter, r *http.Request) {
}
// If the model was changed by routing, update the request body
if req.Model != originalModel {
if decision.TargetModel != decision.OriginalModel {
req.Model = decision.TargetModel
// Re-marshal the request with the updated model
updatedBodyBytes, err := json.Marshal(req)
if err != nil {
@ -107,20 +106,16 @@ func (h *Handler) Messages(w http.ResponseWriter, r *http.Request) {
return
}
// Create a new request with the updated body
// Update the request body
r.Body = io.NopCloser(bytes.NewReader(updatedBodyBytes))
r.ContentLength = int64(len(updatedBodyBytes))
r.Header.Set("Content-Length", fmt.Sprintf("%d", len(updatedBodyBytes)))
// Update the context with new body bytes for logging
ctx := context.WithValue(r.Context(), model.BodyBytesKey, updatedBodyBytes)
r = r.WithContext(ctx)
}
// Forward the request to the selected provider
resp, err := provider.ForwardRequest(r.Context(), r)
resp, err := decision.Provider.ForwardRequest(r.Context(), r)
if err != nil {
log.Printf("❌ Error forwarding to %s API: %v", provider.Name(), err)
log.Printf("❌ Error forwarding to %s API: %v", decision.Provider.Name(), err)
writeErrorResponse(w, "Failed to forward request", http.StatusInternalServerError)
return
}
@ -135,7 +130,6 @@ func (h *Handler) Messages(w http.ResponseWriter, r *http.Request) {
}
func (h *Handler) Models(w http.ResponseWriter, r *http.Request) {
log.Println("📋 Models list requested")
response := &model.ModelsResponse{
Object: "list",
@ -176,7 +170,7 @@ func (h *Handler) Health(w http.ResponseWriter, r *http.Request) {
func (h *Handler) UI(w http.ResponseWriter, r *http.Request) {
htmlContent, err := os.ReadFile("index.html")
if err != nil {
log.Printf("❌ Error reading index.html: %v", err)
// Error reading index.html
http.Error(w, "UI not available", http.StatusNotFound)
return
}
@ -244,17 +238,14 @@ func (h *Handler) GetRequests(w http.ResponseWriter, r *http.Request) {
}
func (h *Handler) DeleteRequests(w http.ResponseWriter, r *http.Request) {
log.Println("🗑️ Clearing request history")
clearedCount, err := h.storageService.ClearRequests()
if err != nil {
log.Printf("Error clearing requests: %v", err)
log.Printf("Error clearing requests: %v", err)
writeErrorResponse(w, "Error clearing request history", http.StatusInternalServerError)
return
}
log.Printf("✅ Deleted %d request files", clearedCount)
response := map[string]interface{}{
"message": "Request history cleared",
"deleted": clearedCount,
@ -268,7 +259,6 @@ func (h *Handler) NotFound(w http.ResponseWriter, r *http.Request) {
}
func (h *Handler) handleStreamingResponse(w http.ResponseWriter, resp *http.Response, requestLog *model.RequestLog, startTime time.Time) {
log.Println("🌊 Streaming response detected, forwarding stream...")
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
@ -341,7 +331,6 @@ func (h *Handler) handleStreamingResponse(w http.ResponseWriter, resp *http.Resp
if reason, ok := message["stop_reason"].(string); ok {
stopReason = reason
}
// Don't capture usage from message_start - it will come in message_delta
}
}
@ -368,7 +357,6 @@ func (h *Handler) handleStreamingResponse(w http.ResponseWriter, resp *http.Resp
finalUsage.CacheReadInputTokens = int(cacheRead)
}
log.Printf("📊 Captured usage from message_delta: %+v", finalUsage)
}
}
@ -430,9 +418,6 @@ func (h *Handler) handleStreamingResponse(w http.ResponseWriter, resp *http.Resp
// Add usage data if we captured it
if finalUsage != nil {
responseBody["usage"] = finalUsage
log.Printf("📊 Final usage data being stored: %+v", finalUsage)
} else {
log.Printf("⚠️ No usage data captured for streaming response - finalUsage is nil")
}
// Marshal to JSON for storage
@ -457,10 +442,6 @@ func (h *Handler) handleStreamingResponse(w http.ResponseWriter, resp *http.Resp
}
func (h *Handler) handleNonStreamingResponse(w http.ResponseWriter, resp *http.Response, requestLog *model.RequestLog, startTime time.Time) {
// Log response headers for debugging
log.Printf("📋 Response headers: Content-Encoding=%s, Content-Type=%s, Status=%d",
resp.Header.Get("Content-Encoding"), resp.Header.Get("Content-Type"), resp.StatusCode)
responseBytes, err := io.ReadAll(resp.Body)
if err != nil {
log.Printf("❌ Error reading Anthropic response: %v", err)
@ -468,11 +449,6 @@ func (h *Handler) handleNonStreamingResponse(w http.ResponseWriter, resp *http.R
return
}
// Log first few bytes to help debug compression issues
if len(responseBytes) > 0 {
log.Printf("📊 Response body starts with: %x (first 10 bytes)", responseBytes[:min(10, len(responseBytes))])
}
responseLog := &model.ResponseLog{
StatusCode: resp.StatusCode,
Headers: SanitizeHeaders(resp.Header),
@ -487,7 +463,6 @@ func (h *Handler) handleNonStreamingResponse(w http.ResponseWriter, resp *http.R
if err := json.Unmarshal(responseBytes, &anthropicResp); err == nil {
// Successfully parsed - store the structured response
responseLog.Body = json.RawMessage(responseBytes)
log.Printf("✅ Successfully parsed Anthropic response")
} else {
// If parsing fails, store as text but log the error
log.Printf("⚠️ Failed to parse Anthropic response: %v", err)
@ -512,7 +487,6 @@ func (h *Handler) handleNonStreamingResponse(w http.ResponseWriter, resp *http.R
return
}
log.Println("✅ Successfully forwarded request to Anthropic API")
w.Header().Set("Content-Type", "application/json")
w.Write(responseBytes)
}
@ -618,7 +592,6 @@ func extractTextFromMessage(message json.RawMessage) string {
// Conversation handlers
func (h *Handler) GetConversations(w http.ResponseWriter, r *http.Request) {
log.Println("📚 Getting conversations from Claude projects")
conversations, err := h.conversationService.GetConversations()
if err != nil {
@ -708,8 +681,6 @@ func (h *Handler) GetConversationByID(w http.ResponseWriter, r *http.Request) {
return
}
log.Printf("📖 Getting conversation %s from project %s", sessionID, projectPath)
conversation, err := h.conversationService.GetConversation(projectPath, sessionID)
if err != nil {
log.Printf("❌ Error getting conversation: %v", err)
@ -727,8 +698,6 @@ func (h *Handler) GetConversationsByProject(w http.ResponseWriter, r *http.Reque
return
}
log.Printf("📁 Getting conversations for project %s", projectPath)
conversations, err := h.conversationService.GetConversationsByProject(projectPath)
if err != nil {
log.Printf("❌ Error getting project conversations: %v", err)

View file

@ -1,287 +0,0 @@
package handler
import (
"bytes"
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"testing"
"github.com/gorilla/mux"
"github.com/seifghazi/claude-code-monitor/internal/config"
"github.com/seifghazi/claude-code-monitor/internal/model"
)
// MockStorageService implements StorageService interface for testing
type MockStorageService struct {
SavedRequests []model.RequestLog
ReturnError error
RequestsToReturn []model.RequestLog
TotalRequests int
}
func (m *MockStorageService) SaveRequest(request *model.RequestLog) (string, error) {
if m.ReturnError != nil {
return "", m.ReturnError
}
m.SavedRequests = append(m.SavedRequests, *request)
return "test-id-123", nil
}
func (m *MockStorageService) GetRequests(page, limit int) ([]model.RequestLog, int, error) {
if m.ReturnError != nil {
return nil, 0, m.ReturnError
}
return m.RequestsToReturn, m.TotalRequests, nil
}
func (m *MockStorageService) ClearRequests() (int, error) {
if m.ReturnError != nil {
return 0, m.ReturnError
}
count := len(m.SavedRequests)
m.SavedRequests = nil
return count, nil
}
func (m *MockStorageService) UpdateRequestWithGrading(requestID string, grade *model.PromptGrade) error {
return m.ReturnError
}
func (m *MockStorageService) UpdateRequestWithResponse(request *model.RequestLog) error {
return m.ReturnError
}
func (m *MockStorageService) EnsureDirectoryExists() error {
return nil
}
func (m *MockStorageService) GetRequestByShortID(shortID string) (*model.RequestLog, string, error) {
if m.ReturnError != nil {
return nil, "", m.ReturnError
}
if len(m.RequestsToReturn) > 0 {
return &m.RequestsToReturn[0], "full-id", nil
}
return nil, "", nil
}
func (m *MockStorageService) GetConfig() *config.StorageConfig {
return &config.StorageConfig{
DBPath: "test.db",
}
}
func (m *MockStorageService) GetAllRequests(modelFilter string) ([]*model.RequestLog, error) {
if m.ReturnError != nil {
return nil, m.ReturnError
}
result := make([]*model.RequestLog, len(m.RequestsToReturn))
for i := range m.RequestsToReturn {
result[i] = &m.RequestsToReturn[i]
}
return result, nil
}
// MockAnthropicService implements AnthropicService interface for testing
type MockAnthropicService struct {
ReturnResponse *http.Response
ReturnError error
ReceivedRequest *http.Request
}
func (m *MockAnthropicService) ForwardRequest(ctx context.Context, originalReq *http.Request) (*http.Response, error) {
m.ReceivedRequest = originalReq
if m.ReturnError != nil {
return nil, m.ReturnError
}
if m.ReturnResponse != nil {
return m.ReturnResponse, nil
}
// Return a default successful response
return &http.Response{
StatusCode: 200,
Body: io.NopCloser(bytes.NewBufferString(`{"id":"test","content":[{"text":"Hello"}]}`)),
Header: make(http.Header),
}, nil
}
func TestHealthEndpoint(t *testing.T) {
// Create handler with mocks
mockStorage := &MockStorageService{}
mockAnthropic := &MockAnthropicService{}
handler := New(mockAnthropic, mockStorage, nil)
// Create test request
req, err := http.NewRequest("GET", "/health", nil)
if err != nil {
t.Fatal(err)
}
// Create response recorder
rr := httptest.NewRecorder()
// Create router and register handler
router := mux.NewRouter()
router.HandleFunc("/health", handler.Health).Methods("GET")
// Serve the request
router.ServeHTTP(rr, req)
// Check status code
if status := rr.Code; status != http.StatusOK {
t.Errorf("Handler returned wrong status code: got %v want %v", status, http.StatusOK)
}
// Check response body
var response map[string]interface{}
if err := json.Unmarshal(rr.Body.Bytes(), &response); err != nil {
t.Errorf("Failed to parse response body: %v", err)
}
if response["status"] != "healthy" {
t.Errorf("Expected status 'healthy', got %v", response["status"])
}
}
func TestGetRequestsEndpoint(t *testing.T) {
// Create mock storage with test data
mockStorage := &MockStorageService{
RequestsToReturn: []model.RequestLog{
{
ID: "test-1",
Method: "POST",
Endpoint: "/v1/messages",
Model: "claude-3-opus",
},
{
ID: "test-2",
Method: "POST",
Endpoint: "/v1/messages",
Model: "claude-3-sonnet",
},
},
TotalRequests: 2,
}
mockAnthropic := &MockAnthropicService{}
handler := New(mockAnthropic, mockStorage, nil)
// Create test request
req, err := http.NewRequest("GET", "/api/requests?page=1&limit=10", nil)
if err != nil {
t.Fatal(err)
}
// Create response recorder
rr := httptest.NewRecorder()
// Create router and register handler
router := mux.NewRouter()
router.HandleFunc("/api/requests", handler.GetRequests).Methods("GET")
// Serve the request
router.ServeHTTP(rr, req)
// Check status code
if status := rr.Code; status != http.StatusOK {
t.Errorf("Handler returned wrong status code: got %v want %v", status, http.StatusOK)
}
// Check response body
var response struct {
Requests []model.RequestLog `json:"requests"`
Total int `json:"total"`
Page int `json:"page"`
Limit int `json:"limit"`
}
if err := json.Unmarshal(rr.Body.Bytes(), &response); err != nil {
t.Errorf("Failed to parse response body: %v", err)
}
if len(response.Requests) != 2 {
t.Errorf("Expected 2 requests, got %d", len(response.Requests))
}
if response.Total != 2 {
t.Errorf("Expected total 2, got %d", response.Total)
}
}
func TestChatCompletionsEndpoint(t *testing.T) {
mockStorage := &MockStorageService{}
mockAnthropic := &MockAnthropicService{}
handler := New(mockAnthropic, mockStorage, nil)
// Create test request
req, err := http.NewRequest("POST", "/v1/chat/completions", bytes.NewBufferString(`{"model":"gpt-4"}`))
if err != nil {
t.Fatal(err)
}
req.Header.Set("Content-Type", "application/json")
// Create response recorder
rr := httptest.NewRecorder()
// Call handler directly
handler.ChatCompletions(rr, req)
// Should return bad request since this is an Anthropic proxy
if status := rr.Code; status != http.StatusBadRequest {
t.Errorf("Handler returned wrong status code: got %v want %v", status, http.StatusBadRequest)
}
// Check error message
var response map[string]interface{}
if err := json.Unmarshal(rr.Body.Bytes(), &response); err != nil {
t.Errorf("Failed to parse response body: %v", err)
}
expectedError := "This is an Anthropic proxy. Please use the /v1/messages endpoint instead of /v1/chat/completions"
if response["error"] != expectedError {
t.Errorf("Expected error message '%s', got %v", expectedError, response["error"])
}
}
func TestDeleteRequestsEndpoint(t *testing.T) {
// Create mock storage
mockStorage := &MockStorageService{
SavedRequests: []model.RequestLog{
{ID: "test-1"},
{ID: "test-2"},
},
}
mockAnthropic := &MockAnthropicService{}
handler := New(mockAnthropic, mockStorage, nil)
// Create test request
req, err := http.NewRequest("DELETE", "/api/requests", nil)
if err != nil {
t.Fatal(err)
}
// Create response recorder
rr := httptest.NewRecorder()
// Create router and register handler
router := mux.NewRouter()
router.HandleFunc("/api/requests", handler.DeleteRequests).Methods("DELETE")
// Serve the request
router.ServeHTTP(rr, req)
// Check status code
if status := rr.Code; status != http.StatusOK {
t.Errorf("Handler returned wrong status code: got %v want %v", status, http.StatusOK)
}
// Check response body
var response map[string]interface{}
if err := json.Unmarshal(rr.Body.Bytes(), &response); err != nil {
t.Errorf("Failed to parse response body: %v", err)
}
if response["deleted"] != float64(2) { // JSON unmarshals numbers as float64
t.Errorf("Expected 2 deleted requests, got %v", response["deleted"])
}
}

View file

@ -3,11 +3,10 @@ package middleware
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"strings"
"time"
"github.com/seifghazi/claude-code-monitor/internal/model"
@ -17,8 +16,9 @@ func Logging(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
// For POST requests with body, read and store the bytes
var bodyBytes []byte
if r.Body != nil {
if r.Body != nil && (r.Method == "POST" || r.Method == "PUT" || r.Method == "PATCH") {
var err error
bodyBytes, err = io.ReadAll(r.Body)
if err != nil {
@ -28,47 +28,29 @@ func Logging(next http.Handler) http.Handler {
}
r.Body.Close()
r.Body = io.NopCloser(bytes.NewReader(bodyBytes))
}
ctx := context.WithValue(r.Context(), model.BodyBytesKey, bodyBytes)
r = r.WithContext(ctx)
// Store raw bytes in context for handler to use
ctx := context.WithValue(r.Context(), model.BodyBytesKey, bodyBytes)
r = r.WithContext(ctx)
}
wrapped := &responseWriter{ResponseWriter: w, statusCode: http.StatusOK}
next.ServeHTTP(wrapped, r)
duration := time.Since(start)
log.Printf("Response: %d %s (took %v)", wrapped.statusCode, http.StatusText(wrapped.statusCode), duration)
statusColor := getStatusColor(wrapped.statusCode)
log.Printf("%s %s %s%d%s %s (%s)",
r.Method,
r.URL.Path,
statusColor,
wrapped.statusCode,
colorReset,
http.StatusText(wrapped.statusCode),
formatDuration(duration))
})
}
func formatHeaders(headers http.Header) string {
headerMap := make(map[string][]string)
for k, v := range headers {
headerMap[k] = sanitizeHeaderValue(k, v)
}
headerBytes, _ := json.MarshalIndent(headerMap, "", " ")
return string(headerBytes)
}
func sanitizeHeaderValue(key string, values []string) []string {
lowerKey := strings.ToLower(key)
sensitiveHeaders := []string{
"x-api-key",
"api-key",
"authorization",
"anthropic-api-key",
"openai-api-key",
"bearer",
}
for _, sensitive := range sensitiveHeaders {
if strings.Contains(lowerKey, sensitive) {
return []string{"[REDACTED]"}
}
}
return values
}
type responseWriter struct {
http.ResponseWriter
statusCode int
@ -78,3 +60,37 @@ func (rw *responseWriter) WriteHeader(code int) {
rw.statusCode = code
rw.ResponseWriter.WriteHeader(code)
}
// ANSI color codes
const (
colorReset = "\033[0m"
colorGreen = "\033[32m"
colorYellow = "\033[33m"
colorRed = "\033[31m"
colorBlue = "\033[34m"
colorCyan = "\033[36m"
)
func getStatusColor(status int) string {
switch {
case status >= 200 && status < 300:
return colorGreen
case status >= 300 && status < 400:
return colorBlue
case status >= 400 && status < 500:
return colorYellow
case status >= 500:
return colorRed
default:
return colorReset
}
}
func formatDuration(d time.Duration) string {
if d < time.Millisecond {
return fmt.Sprintf("%dµs", d.Microseconds())
} else if d < time.Second {
return fmt.Sprintf("%dms", d.Milliseconds())
}
return fmt.Sprintf("%.2fs", d.Seconds())
}

View file

@ -98,7 +98,7 @@ func (p *OpenAIProvider) ForwardRequest(ctx context.Context, originalReq *http.R
resp.Body.Close()
// Log the error details
fmt.Printf("OpenAI API error: Status=%d, Body=%s\n", resp.StatusCode, string(errorBody))
// OpenAI API error - will be returned to client
// Create an error response in Anthropic format
errorResp := map[string]interface{}{
@ -335,7 +335,7 @@ func convertAnthropicToOpenAI(req *model.AnthropicRequest) map[string]interface{
// Check if max_tokens exceeds the model's limit and cap it if necessary
maxTokensLimit := 16384 // Assuming this is the limit for the model
if req.MaxTokens > maxTokensLimit {
fmt.Printf("Warning: max_tokens is too large: %d. Capping to %d.\n", req.MaxTokens, maxTokensLimit)
// Capping max_tokens to model limit
req.MaxTokens = maxTokensLimit
}
@ -361,16 +361,13 @@ func convertAnthropicToOpenAI(req *model.AnthropicRequest) map[string]interface{
if !isOSeriesModel {
openAIReq["temperature"] = req.Temperature
}
fmt.Printf("Using max_completion_tokens=%d for model %s\n", req.MaxTokens, req.Model)
// Convert Anthropic tools to OpenAI format
if len(req.Tools) > 0 {
tools := make([]map[string]interface{}, 0, len(req.Tools))
for _, tool := range req.Tools {
// Ensure tool has required fields
if tool.Name == "" {
fmt.Printf("Warning: Skipping tool with empty name\n")
// Skip tools with empty names
continue
}
@ -391,7 +388,7 @@ func convertAnthropicToOpenAI(req *model.AnthropicRequest) map[string]interface{
if propType, hasType := prop["type"]; hasType && propType == "array" {
if _, hasItems := prop["items"]; !hasItems {
// Add default items definition for arrays
fmt.Printf("Warning: Array property '%s' in tool '%s' missing items - adding default\n", propName, tool.Name)
// Add default items for array properties missing them
prop["items"] = map[string]interface{}{"type": "string"}
}
}
@ -520,7 +517,7 @@ func transformOpenAIResponseToAnthropic(respBody []byte) []byte {
anthropicToolUse["input"] = args
} else {
// If parsing fails, wrap in a raw field like Python does
fmt.Printf("Warning: Failed to parse tool arguments as JSON: %v\n", err)
// Failed to parse tool arguments - skip
anthropicToolUse["input"] = map[string]interface{}{"raw": argsStr}
}
} else if args, ok := function["arguments"].(map[string]interface{}); ok {
@ -620,22 +617,9 @@ func transformOpenAIStreamToAnthropic(openAIStream io.ReadCloser, anthropicStrea
continue
}
// Debug: Check if this is the final chunk
if choices, ok := openAIChunk["choices"].([]interface{}); ok && len(choices) > 0 {
if choice, ok := choices[0].(map[string]interface{}); ok {
if finishReason, ok := choice["finish_reason"]; ok && finishReason != nil {
fmt.Printf("🏁 Final chunk detected with finish_reason: %v\n", finishReason)
fmt.Printf("🏁 Full final chunk: %+v\n", openAIChunk)
}
}
}
// Check for usage data BEFORE processing choices
// According to OpenAI docs, usage is sent in the final chunk with empty choices array
if usage, hasUsage := openAIChunk["usage"].(map[string]interface{}); hasUsage {
fmt.Printf("🔍 Found usage data in OpenAI stream: %+v\n", usage)
fmt.Printf("🔍 Full OpenAI chunk with usage: %+v\n", openAIChunk)
// Convert OpenAI usage to Anthropic format
anthropicUsage := map[string]interface{}{}

View file

@ -11,5 +11,5 @@ type Provider interface {
Name() string
// ForwardRequest forwards a request to the provider's API
ForwardRequest(ctx context.Context, originalReq *http.Request) (*http.Response, error)
ForwardRequest(ctx context.Context, req *http.Request) (*http.Response, error)
}

View file

@ -30,29 +30,29 @@ func NewConversationService() ConversationService {
// ConversationMessage represents a single message in a Claude conversation
type ConversationMessage struct {
ParentUUID *string `json:"parentUuid"`
IsSidechain bool `json:"isSidechain"`
UserType string `json:"userType"`
CWD string `json:"cwd"`
SessionID string `json:"sessionId"`
Version string `json:"version"`
Type string `json:"type"`
Message json.RawMessage `json:"message"`
UUID string `json:"uuid"`
Timestamp string `json:"timestamp"`
ParsedTime time.Time `json:"-"`
ParentUUID *string `json:"parentUuid"`
IsSidechain bool `json:"isSidechain"`
UserType string `json:"userType"`
CWD string `json:"cwd"`
SessionID string `json:"sessionId"`
Version string `json:"version"`
Type string `json:"type"`
Message json.RawMessage `json:"message"`
UUID string `json:"uuid"`
Timestamp string `json:"timestamp"`
ParsedTime time.Time `json:"-"`
}
// Conversation represents a complete conversation session
type Conversation struct {
SessionID string `json:"sessionId"`
ProjectPath string `json:"projectPath"`
ProjectName string `json:"projectName"`
Messages []*ConversationMessage `json:"messages"`
StartTime time.Time `json:"startTime"`
EndTime time.Time `json:"endTime"`
MessageCount int `json:"messageCount"`
FileModTime time.Time `json:"-"` // Used for sorting, not exported
SessionID string `json:"sessionId"`
ProjectPath string `json:"projectPath"`
ProjectName string `json:"projectName"`
Messages []*ConversationMessage `json:"messages"`
StartTime time.Time `json:"startTime"`
EndTime time.Time `json:"endTime"`
MessageCount int `json:"messageCount"`
FileModTime time.Time `json:"-"` // Used for sorting, not exported
}
// GetConversations returns all conversations organized by project
@ -74,7 +74,7 @@ func (cs *conversationService) GetConversations() (map[string][]*Conversation, e
// Get the project path relative to claudeProjectsPath
projectDir := filepath.Dir(path)
projectRelPath, _ := filepath.Rel(cs.claudeProjectsPath, projectDir)
// Skip files directly in the projects directory
if projectRelPath == "." || projectRelPath == "" {
return nil
@ -99,18 +99,7 @@ func (cs *conversationService) GetConversations() (map[string][]*Conversation, e
return nil, fmt.Errorf("failed to walk claude projects: %w", err)
}
// Log any parsing errors encountered
if len(parseErrors) > 0 {
fmt.Printf("Warning: Encountered %d parsing errors while loading conversations:\n", len(parseErrors))
for i, err := range parseErrors {
if i < 5 { // Only show first 5 errors to avoid spam
fmt.Printf(" - %s\n", err)
}
}
if len(parseErrors) > 5 {
fmt.Printf(" ... and %d more errors\n", len(parseErrors)-5)
}
}
// Some parsing errors may have occurred but were handled
// Sort conversations within each project by file modification time (newest first)
for project := range conversations {
@ -125,7 +114,7 @@ func (cs *conversationService) GetConversations() (map[string][]*Conversation, e
// GetConversation returns a specific conversation by project and session ID
func (cs *conversationService) GetConversation(projectPath, sessionID string) (*Conversation, error) {
filePath := filepath.Join(cs.claudeProjectsPath, projectPath, sessionID+".jsonl")
conv, err := cs.parseConversationFile(filePath, projectPath)
if err != nil {
return nil, fmt.Errorf("failed to parse conversation: %w", err)
@ -175,7 +164,7 @@ func (cs *conversationService) parseConversationFile(filePath, projectPath strin
if err != nil {
return nil, fmt.Errorf("failed to stat file: %w", err)
}
file, err := os.Open(filePath)
if err != nil {
return nil, fmt.Errorf("failed to open file: %w", err)
@ -185,9 +174,9 @@ func (cs *conversationService) parseConversationFile(filePath, projectPath strin
var messages []*ConversationMessage
var parseErrors int
lineNum := 0
scanner := bufio.NewScanner(file)
// Increase buffer size for large messages
const maxScanTokenSize = 10 * 1024 * 1024 // 10MB
buf := make([]byte, maxScanTokenSize)
@ -196,18 +185,18 @@ func (cs *conversationService) parseConversationFile(filePath, projectPath strin
for scanner.Scan() {
lineNum++
line := scanner.Bytes()
// Skip empty lines
if len(line) == 0 {
continue
}
var msg ConversationMessage
if err := json.Unmarshal(line, &msg); err != nil {
parseErrors++
// Log only first few errors to avoid spam
if parseErrors <= 3 {
fmt.Printf("Warning: Failed to parse line %d in %s: %v\n", lineNum, filePath, err)
// Skip malformed line
}
continue
}
@ -219,7 +208,7 @@ func (cs *conversationService) parseConversationFile(filePath, projectPath strin
// Try alternative timestamp formats
parsedTime, err = time.Parse(time.RFC3339Nano, msg.Timestamp)
if err != nil {
fmt.Printf("Warning: Failed to parse timestamp '%s' in %s\n", msg.Timestamp, filePath)
// Skip message with invalid timestamp
}
}
msg.ParsedTime = parsedTime
@ -233,7 +222,7 @@ func (cs *conversationService) parseConversationFile(filePath, projectPath strin
}
if parseErrors > 3 {
fmt.Printf("Warning: Total of %d lines failed to parse in %s\n", parseErrors, filePath)
// Some lines failed to parse but were skipped
}
// Return empty conversation if no messages (caller can decide what to do)
@ -303,4 +292,4 @@ func (cs *conversationService) parseConversationFile(filePath, projectPath strin
MessageCount: len(messages),
FileModTime: fileInfo.ModTime(),
}, nil
}
}

View file

@ -13,11 +13,19 @@ import (
"github.com/seifghazi/claude-code-monitor/internal/provider"
)
// RoutingDecision contains the result of routing analysis
type RoutingDecision struct {
Provider provider.Provider
OriginalModel string
TargetModel string
}
type ModelRouter struct {
config *config.Config
providers map[string]provider.Provider
subagentMappings map[string]string // agentName -> targetModel
customAgentPrompts map[string]SubagentDefinition // promptHash -> definition
modelProviderMap map[string]string // model -> provider mapping
logger *log.Logger
}
@ -34,13 +42,73 @@ func NewModelRouter(cfg *config.Config, providers map[string]provider.Provider,
providers: providers,
subagentMappings: cfg.Subagents.Mappings,
customAgentPrompts: make(map[string]SubagentDefinition),
modelProviderMap: initializeModelProviderMap(),
logger: logger,
}
router.loadCustomAgents()
// Only load custom agents if subagents are enabled
if cfg.Subagents.Enable {
router.loadCustomAgents()
} else {
logger.Println("")
logger.Println(" Subagent routing is disabled")
logger.Println(" Enable it in config.yaml to route Claude Code agents to different LLM providers")
logger.Println("")
}
return router
}
// initializeModelProviderMap creates a mapping of model names to their providers
func initializeModelProviderMap() map[string]string {
modelMap := make(map[string]string)
// OpenAI models
openaiModels := []string{
// GPT-4.1 family
"gpt-4.1", "gpt-4.1-2025-04-14",
"gpt-4.1-mini", "gpt-4.1-mini-2025-04-14",
"gpt-4.1-nano", "gpt-4.1-nano-2025-04-14",
// GPT-4.5
"gpt-4.5-preview", "gpt-4.5-preview-2025-02-27",
// GPT-4o variants
"gpt-4o", "gpt-4o-2024-08-06",
"gpt-4o-mini", "gpt-4o-mini-2024-07-18",
// GPT-3.5 variants
"gpt-3.5-turbo", "gpt-3.5-turbo-0125", "gpt-3.5-turbo-1106", "gpt-3.5-turbo-instruct",
// O1 series
"o1", "o1-2024-12-17",
"o1-pro", "o1-pro-2025-03-19",
"o1-mini", "o1-mini-2024-09-12",
// O3 series
"o3-pro", "o3-pro-2025-06-10",
"o3", "o3-2025-04-16",
"o3-mini", "o3-mini-2025-01-31",
}
for _, model := range openaiModels {
modelMap[model] = "openai"
}
// Anthropic models
anthropicModels := []string{
"claude-opus-4-20250514",
"claude-sonnet-4-20250514",
"claude-3-7-sonnet-20250219",
"claude-3-5-haiku-20241022",
}
for _, model := range anthropicModels {
modelMap[model] = "anthropic"
}
return modelMap
}
// extractStaticPrompt extracts the portion before "Notes:" if it exists
func (r *ModelRouter) extractStaticPrompt(systemPrompt string) string {
// Find the "Notes:" section
@ -59,8 +127,6 @@ func (r *ModelRouter) extractStaticPrompt(systemPrompt string) string {
}
func (r *ModelRouter) loadCustomAgents() {
r.logger.Printf("Loading custom agents from mappings: %+v", r.subagentMappings)
for agentName, targetModel := range r.subagentMappings {
// Try loading from project level first, then user level
paths := []string{
@ -69,28 +135,21 @@ func (r *ModelRouter) loadCustomAgents() {
}
for _, path := range paths {
r.logger.Printf("Trying to load agent from: %s", path)
content, err := os.ReadFile(path)
if err != nil {
r.logger.Printf("Failed to read %s: %v", path, err)
continue
}
r.logger.Printf("Successfully read agent file: %s (size: %d bytes)", path, len(content))
// Parse agent file: metadata\n---\nsystem prompt
parts := strings.Split(string(content), "\n---\n")
r.logger.Printf("Agent file parts: %d", len(parts))
if len(parts) >= 2 {
systemPrompt := strings.TrimSpace(parts[1])
r.logger.Printf("System prompt (first 200 chars): %.200s", systemPrompt)
// Extract only the static part (before "Notes:" if it exists)
staticPrompt := r.extractStaticPrompt(systemPrompt)
hash := r.hashString(staticPrompt)
r.logger.Printf("Static prompt after extraction (first 200 chars): %.200s", staticPrompt)
// Determine provider for the target model
providerName := r.getProviderNameForModel(targetModel)
@ -100,35 +159,47 @@ func (r *ModelRouter) loadCustomAgents() {
TargetProvider: providerName,
FullPrompt: staticPrompt,
}
r.logger.Printf("Loaded custom agent: %s (hash: %s) -> %s (provider: %s)",
agentName, hash, targetModel, providerName)
break
} else {
r.logger.Printf("Invalid agent file format for %s: expected at least 2 parts separated by ---", agentName)
}
}
}
r.logger.Printf("Total custom agents loaded: %d", len(r.customAgentPrompts))
// Pretty print loaded subagents
if len(r.customAgentPrompts) > 0 {
r.logger.Println("")
r.logger.Println("🤖 Subagent Model Mappings:")
r.logger.Println("──────────────────────────────────────")
for _, def := range r.customAgentPrompts {
r.logger.Printf(" \033[36m%s\033[0m → \033[32m%s\033[0m",
def.Name, def.TargetModel)
}
r.logger.Println("──────────────────────────────────────")
r.logger.Println("")
}
}
// RouteRequest determines which provider and model to use for a request
func (r *ModelRouter) RouteRequest(req *model.AnthropicRequest) (provider.Provider, string, error) {
originalModel := req.Model
// DetermineRoute analyzes the request and returns routing information without modifying the request
func (r *ModelRouter) DetermineRoute(req *model.AnthropicRequest) (*RoutingDecision, error) {
decision := &RoutingDecision{
OriginalModel: req.Model,
TargetModel: req.Model, // default to original
}
r.logger.Printf("RouteRequest: Model=%s, System messages count=%d", originalModel, len(req.System))
// Debug: Print loaded custom agents
r.logger.Printf("Loaded custom agents: %d", len(r.customAgentPrompts))
for hash, def := range r.customAgentPrompts {
r.logger.Printf(" Agent: %s (hash: %s) -> %s", def.Name, hash, def.TargetModel)
// Check if subagents are enabled
if !r.config.Subagents.Enable {
// Subagents disabled, use default provider
providerName := r.getProviderNameForModel(decision.TargetModel)
decision.Provider = r.providers[providerName]
if decision.Provider == nil {
return nil, fmt.Errorf("no provider found for model %s", decision.TargetModel)
}
return decision, nil
}
// Claude Code pattern: Check if we have exactly 2 system messages
if len(req.System) == 2 {
r.logger.Printf("System[0]: %.100s...", req.System[0].Text)
r.logger.Printf("System[1]: %.100s...", req.System[1].Text)
// First should be "You are Claude Code..."
if strings.Contains(req.System[0].Text, "You are Claude Code") {
@ -142,37 +213,31 @@ func (r *ModelRouter) RouteRequest(req *model.AnthropicRequest) (provider.Provid
staticPrompt := r.extractStaticPrompt(fullPrompt)
promptHash := r.hashString(staticPrompt)
r.logger.Printf("Static prompt hash: %s", promptHash)
r.logger.Printf("Static prompt (first 200 chars): %.200s", staticPrompt)
// Check if this matches a known custom agent
if definition, exists := r.customAgentPrompts[promptHash]; exists {
r.logger.Printf("Subagent '%s' detected -> routing to %s",
definition.Name, definition.TargetModel)
r.logger.Printf("\033[36m%s\033[0m → \033[32m%s\033[0m",
req.Model, definition.TargetModel)
req.Model = definition.TargetModel
provider := r.providers[definition.TargetProvider]
if provider == nil {
return nil, originalModel, fmt.Errorf("provider %s not found for model %s",
decision.TargetModel = definition.TargetModel
decision.Provider = r.providers[definition.TargetProvider]
if decision.Provider == nil {
return nil, fmt.Errorf("provider %s not found for model %s",
definition.TargetProvider, definition.TargetModel)
}
return provider, originalModel, nil
return decision, nil
}
// This is a regular Claude Code request (not a known subagent)
r.logger.Printf("No matching subagent found for hash %s, using original model %s", promptHash, originalModel)
}
}
// Default: use the original model and its provider
providerName := r.getProviderNameForModel(originalModel)
provider := r.providers[providerName]
if provider == nil {
return nil, originalModel, fmt.Errorf("no provider found for model %s", originalModel)
providerName := r.getProviderNameForModel(decision.TargetModel)
decision.Provider = r.providers[providerName]
if decision.Provider == nil {
return nil, fmt.Errorf("no provider found for model %s", decision.TargetModel)
}
return provider, originalModel, nil
return decision, nil
}
func (r *ModelRouter) hashString(s string) string {
@ -180,17 +245,15 @@ func (r *ModelRouter) hashString(s string) string {
h.Write([]byte(s))
fullHash := hex.EncodeToString(h.Sum(nil))
shortHash := fullHash[:16]
r.logger.Printf("Hashing string (length: %d) -> %s", len(s), shortHash)
return shortHash
}
func (r *ModelRouter) getProviderNameForModel(model string) string {
// Map models to providers
if strings.HasPrefix(model, "claude") {
return "anthropic"
} else if strings.HasPrefix(model, "gpt") || strings.HasPrefix(model, "o") {
return "openai"
if provider, exists := r.modelProviderMap[model]; exists {
return provider
}
// Default to anthropic
r.logger.Printf("⚠️ Model '%s' doesn't match any known patterns, defaulting to anthropic", model)
return "anthropic"
}

View file

@ -21,7 +21,6 @@ func TestModelRouter_EdgeCases(t *testing.T) {
}
providers := make(map[string]provider.Provider)
// Mock providers - in real test you'd use mocks
providers["anthropic"] = nil
providers["openai"] = nil
@ -74,9 +73,6 @@ func TestModelRouter_EdgeCases(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Since we can't test with real providers, we'll just test the logic
// by checking that extractStaticPrompt works correctly
if len(tt.request.System) == 2 {
// Test extract static prompt for second message
fullPrompt := tt.request.System[1].Text

View file

@ -4,7 +4,6 @@ import (
"database/sql"
"encoding/json"
"fmt"
"log"
"strings"
_ "github.com/mattn/go-sqlite3"
@ -146,19 +145,19 @@ func (s *sqliteStorageService) GetRequests(page, limit int) ([]model.RequestLog,
&req.RoutedModel,
)
if err != nil {
log.Printf("Error scanning row: %v", err)
// Error scanning row - skip
continue
}
// Unmarshal JSON fields
if err := json.Unmarshal([]byte(headersJSON), &req.Headers); err != nil {
log.Printf("Error unmarshaling headers: %v", err)
// Error unmarshaling headers
continue
}
var body interface{}
if err := json.Unmarshal([]byte(bodyJSON), &body); err != nil {
log.Printf("Error unmarshaling body: %v", err)
// Error unmarshaling body
continue
}
req.Body = body
@ -310,7 +309,7 @@ func (s *sqliteStorageService) GetAllRequests(modelFilter string) ([]*model.Requ
if modelFilter != "" && modelFilter != "all" {
query += " WHERE LOWER(model) LIKE ?"
args = append(args, "%"+strings.ToLower(modelFilter)+"%")
log.Printf("🔍 SQL Query with filter: %s, args: %v", query, args)
}
query += " ORDER BY timestamp DESC"
@ -343,21 +342,19 @@ func (s *sqliteStorageService) GetAllRequests(modelFilter string) ([]*model.Requ
&req.RoutedModel,
)
if err != nil {
log.Printf("Error scanning row: %v", err)
// Error scanning row - skip
continue
}
// log.Printf("🔍 Scanned request - ID: %s, Model: %s", req.RequestID, req.Model)
// Unmarshal JSON fields
if err := json.Unmarshal([]byte(headersJSON), &req.Headers); err != nil {
log.Printf("Error unmarshaling headers: %v", err)
// Error unmarshaling headers
continue
}
var body interface{}
if err := json.Unmarshal([]byte(bodyJSON), &body); err != nil {
log.Printf("Error unmarshaling body: %v", err)
// Error unmarshaling body
continue
}
req.Body = body

BIN
proxy/proxy Executable file

Binary file not shown.

View file

@ -1,136 +0,0 @@
#!/bin/bash
# End-to-End test script for LLM Proxy
# This script starts the server, runs basic tests, and cleans up
set -e
echo "🧪 Starting End-to-End Tests for LLM Proxy"
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
# Test configuration
TEST_PORT=3002
TEST_DB="test_requests.db"
TEST_CONFIG="test_config.yaml"
# Cleanup function
cleanup() {
echo "🧹 Cleaning up..."
if [ ! -z "$SERVER_PID" ]; then
kill $SERVER_PID 2>/dev/null || true
fi
rm -f $TEST_DB $TEST_CONFIG
}
# Set trap to cleanup on exit
trap cleanup EXIT
# Create test configuration
echo "📝 Creating test configuration..."
cat > $TEST_CONFIG << EOF
server:
port: $TEST_PORT
timeouts:
read: 1m
write: 1m
idle: 1m
providers:
anthropic:
base_url: "https://api.anthropic.com"
version: "2023-06-01"
max_retries: 1
storage:
db_path: "$TEST_DB"
EOF
# Build the proxy
echo "🔨 Building proxy..."
cd proxy && go build -o ../bin/test-proxy cmd/proxy/main.go && cd ..
# Start the server
echo "🚀 Starting test server on port $TEST_PORT..."
CONFIG_PATH=$TEST_CONFIG PORT=$TEST_PORT ./bin/test-proxy &
SERVER_PID=$!
# Wait for server to start
echo "⏳ Waiting for server to start..."
sleep 3
# Function to check response
check_response() {
local endpoint=$1
local expected_status=$2
local test_name=$3
response=$(curl -s -w "\n%{http_code}" http://localhost:$TEST_PORT$endpoint)
status_code=$(echo "$response" | tail -n 1)
body=$(echo "$response" | head -n -1)
if [ "$status_code" = "$expected_status" ]; then
echo -e "${GREEN}${NC} $test_name: Status $status_code"
return 0
else
echo -e "${RED}${NC} $test_name: Expected $expected_status, got $status_code"
echo "Response body: $body"
return 1
fi
}
# Run tests
echo ""
echo "🧪 Running tests..."
echo ""
# Test 1: Health check
check_response "/health" "200" "Health check"
# Test 2: Get requests (should be empty initially)
response=$(curl -s http://localhost:$TEST_PORT/api/requests)
if echo "$response" | grep -q '"requests":\[\]'; then
echo -e "${GREEN}${NC} Get requests: Returns empty array initially"
else
echo -e "${RED}${NC} Get requests: Expected empty array"
echo "Response: $response"
fi
# Test 3: Models endpoint
check_response "/v1/models" "200" "Models endpoint"
# Test 4: Invalid endpoint
check_response "/invalid" "404" "404 for invalid endpoint"
# Test 5: Chat completions endpoint (should return helpful error)
response=$(curl -s -X POST -H "Content-Type: application/json" \
-d '{"model":"gpt-4","messages":[]}' \
http://localhost:$TEST_PORT/v1/chat/completions)
if echo "$response" | grep -q "This is an Anthropic proxy"; then
echo -e "${GREEN}${NC} Chat completions: Returns helpful error message"
else
echo -e "${RED}${NC} Chat completions: Expected Anthropic proxy error"
echo "Response: $response"
fi
# Test 6: Delete requests
response=$(curl -s -X DELETE http://localhost:$TEST_PORT/api/requests)
if echo "$response" | grep -q '"deleted":0'; then
echo -e "${GREEN}${NC} Delete requests: Works with empty database"
else
echo -e "${RED}${NC} Delete requests: Expected deletion count"
echo "Response: $response"
fi
# Test 7: Conversations endpoint
check_response "/api/conversations" "200" "Conversations endpoint"
echo ""
echo "🎉 End-to-End tests completed!"
echo ""
# Cleanup is handled by trap

View file

@ -108,18 +108,6 @@ export function ConversationThread({ conversation }: ConversationThreadProps) {
const messages = analyzeConversationFlow();
// Debug logging to identify assistant response issues
console.log('Conversation Debug:', {
messageCount: conversation.messageCount,
totalMessages: messages.length,
messages: messages.map(m => ({
role: m.role,
contentPreview: JSON.stringify(m.content)?.substring(0, 50),
turn: m.turnNumber,
ts: m.timestamp,
})),
});
if (messages.length === 0) {
return (
<div className="text-center py-12">

View file

@ -82,7 +82,7 @@ interface RequestDetailContentProps {
export default function RequestDetailContent({ request, onGrade }: RequestDetailContentProps) {
const [expandedSections, setExpandedSections] = useState<Record<string, boolean>>({
overview: true,
conversation: true
// conversation: true
});
const [copied, setCopied] = useState<Record<string, boolean>>({});
@ -352,7 +352,7 @@ export default function RequestDetailContent({ request, onGrade }: RequestDetail
{request.routedModel}
</code>
<span className="text-xs bg-blue-100 text-blue-700 px-2 py-1 rounded-full border border-blue-200">
{request.routedModel.startsWith('gpt-') ? 'OpenAI' : 'Anthropic'}
{request.routedModel.startsWith('gpt-') || request.routedModel.startsWith('o') ? 'OpenAI' : 'Anthropic'}
</span>
</div>
</div>

View file

@ -16,11 +16,6 @@ interface TodoListProps {
}
export function TodoList({ todos }: TodoListProps) {
// Debug: Log the structure of the first todo
if (todos && todos.length > 0) {
console.log('Todo structure:', todos[0]);
}
if (!todos || todos.length === 0) {
return (
<div className="bg-gray-50 border border-gray-200 rounded-lg p-4 text-center">

View file

@ -190,53 +190,8 @@ export default function Index() {
});
} catch (error) {
console.error('Failed to load requests:', error);
// Fallback to example data for demo
const exampleRequest = {
timestamp: "2025-06-04T23:47:37-04:00",
method: "POST",
endpoint: "/v1/messages",
headers: {
"User-Agent": ["claude-cli/1.0.11 (external, cli)"],
"Content-Type": ["application/json"],
"Anthropic-Version": ["2023-06-01"]
},
body: {
model: "claude-sonnet-4-20250514",
messages: [
{
role: "user",
content: [{
type: "text",
text: "I need to extract the complete list of tools available to Claude Code from the request file..."
}]
}
],
max_tokens: 32000,
temperature: 1,
stream: true
}
};
startTransition(() => {
// setRequests([
// { ...exampleRequest, id: 1 },
// {
// ...exampleRequest,
// id: 2,
// timestamp: "2025-06-04T23:45:12-04:00",
// endpoint: "/v1/chat/completions",
// body: { ...exampleRequest.body, model: "gpt-4-turbo" }
// },
// {
// ...exampleRequest,
// id: 3,
// timestamp: "2025-06-04T23:42:33-04:00",
// method: "GET",
// endpoint: "/v1/models",
// body: undefined
// }
// ]);
setRequests([]);
});
} finally {
setIsFetching(false);
@ -528,6 +483,26 @@ export default function Index() {
}
}, [viewMode, modelFilter]);
// Handle escape key to close modals
useEffect(() => {
const handleEscapeKey = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
if (isModalOpen) {
closeModal();
} else if (isConversationModalOpen) {
setIsConversationModalOpen(false);
setSelectedConversation(null);
}
}
};
window.addEventListener('keydown', handleEscapeKey);
return () => {
window.removeEventListener('keydown', handleEscapeKey);
};
}, [isModalOpen, isConversationModalOpen]);
const filteredRequests = filterRequests(filter);
return (