route cleanup
working Working version
This commit is contained in:
parent
1e0173c768
commit
4675fee4a3
22 changed files with 361 additions and 944 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 != "" {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"])
|
||||
}
|
||||
}
|
||||
|
|
@ -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())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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{}{}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
BIN
proxy/proxy
Executable file
Binary file not shown.
|
|
@ -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
|
||||
Loading…
Add table
Add a link
Reference in a new issue