diff --git a/proxy/go.mod b/proxy/go.mod index bda1306..3782d6a 100644 --- a/proxy/go.mod +++ b/proxy/go.mod @@ -5,9 +5,7 @@ go 1.20 require ( github.com/gorilla/handlers v1.5.2 github.com/gorilla/mux v1.8.1 + github.com/mattn/go-sqlite3 v1.14.28 ) -require ( - github.com/felixge/httpsnoop v1.0.3 // indirect - github.com/mattn/go-sqlite3 v1.14.28 // indirect -) +require github.com/felixge/httpsnoop v1.0.3 // indirect diff --git a/proxy/internal/config/config.go b/proxy/internal/config/config.go index 94d44c7..3e9619f 100644 --- a/proxy/internal/config/config.go +++ b/proxy/internal/config/config.go @@ -34,9 +34,9 @@ func Load() (*Config, error) { cfg := &Config{ Server: ServerConfig{ Port: getEnv("PORT", "3001"), - ReadTimeout: getDuration("READ_TIMEOUT", 500*time.Second), - WriteTimeout: getDuration("WRITE_TIMEOUT", 500*time.Second), - IdleTimeout: getDuration("IDLE_TIMEOUT", 500*time.Second), + ReadTimeout: getDuration("READ_TIMEOUT", 600*time.Second), // Increased to 10 minutes + WriteTimeout: getDuration("WRITE_TIMEOUT", 600*time.Second), // Increased to 10 minutes + IdleTimeout: getDuration("IDLE_TIMEOUT", 600*time.Second), // Increased to 10 minutes }, Anthropic: AnthropicConfig{ BaseURL: getEnv("ANTHROPIC_FORWARD_URL", "https://api.anthropic.com"), diff --git a/proxy/internal/handler/handlers.go b/proxy/internal/handler/handlers.go index b38d0f6..44ca5da 100644 --- a/proxy/internal/handler/handlers.go +++ b/proxy/internal/handler/handlers.go @@ -40,80 +40,9 @@ 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)") - bodyBytes := getBodyBytes(r) - if bodyBytes == nil { - http.Error(w, "Error reading request body", http.StatusBadRequest) - return - } - - var req model.ChatCompletionRequest - if err := json.Unmarshal(bodyBytes, &req); err != nil { - log.Printf("❌ Error parsing JSON: %v", err) - writeErrorResponse(w, "Invalid JSON", http.StatusBadRequest) - return - } - - requestID := generateRequestID() - startTime := time.Now() - - requestLog := &model.RequestLog{ - RequestID: requestID, - Timestamp: time.Now().Format(time.RFC3339), - Method: r.Method, - Endpoint: "/v1/chat/completions", - Headers: SanitizeHeaders(r.Header), - Body: req, - Model: req.Model, - UserAgent: r.Header.Get("User-Agent"), - ContentType: r.Header.Get("Content-Type"), - } - - if _, err := h.storageService.SaveRequest(requestLog); err != nil { - log.Printf("❌ Error saving request: %v", err) - } - - response := &model.ChatCompletionResponse{ - ID: fmt.Sprintf("chatcmpl-%d", time.Now().UnixNano()), - Object: "chat.completion", - Created: time.Now().Unix(), - Model: req.Model, - Choices: []model.Choice{ - { - Index: 0, - Message: model.ChatMessage{ - Role: "assistant", - Content: "Hello! This is a test response from the refactored proxy server.", - }, - FinishReason: "stop", - }, - }, - Usage: model.Usage{ - PromptTokens: 10, - CompletionTokens: 20, - TotalTokens: 30, - }, - } - - if req.Model == "" { - response.Model = "claude-3-sonnet" - } - - responseLog := &model.ResponseLog{ - StatusCode: http.StatusOK, - Headers: SanitizeHeaders(w.Header()), - Body: response, - ResponseTime: time.Since(startTime).Milliseconds(), - IsStreaming: false, - } - - // The requestLog object has the conversation details. - // We need to set the response on it and then save the update. - requestLog.Response = responseLog - if err := h.storageService.UpdateRequestWithResponse(requestLog); err != nil { - log.Printf("❌ Error updating request with response: %v", err) - } - - writeJSONResponse(w, response) + // 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) { @@ -132,18 +61,6 @@ func (h *Handler) Messages(w http.ResponseWriter, r *http.Request) { return } - // Extract API key from incoming request headers - apiKey := r.Header.Get("x-api-key") - if apiKey == "" { - // Also check for X-Api-Key (capitalized version) - apiKey = r.Header.Get("X-Api-Key") - } - if apiKey == "" { - log.Println("❌ No API key provided in request headers") - writeErrorResponse(w, "API key required in x-api-key header", http.StatusUnauthorized) - return - } - requestID := generateRequestID() startTime := time.Now() @@ -165,7 +82,7 @@ func (h *Handler) Messages(w http.ResponseWriter, r *http.Request) { } // Forward the request to Anthropic - resp, err := h.anthropicService.ForwardRequest(r.Context(), &req, apiKey) + resp, err := h.anthropicService.ForwardRequest(r.Context(), r) if err != nil { log.Printf("❌ Error forwarding to Anthropic API: %v", err) writeErrorResponse(w, "Failed to forward request", http.StatusInternalServerError) @@ -354,6 +271,10 @@ func (h *Handler) handleStreamingResponse(w http.ResponseWriter, resp *http.Resp var fullResponseText strings.Builder var toolCalls []model.ContentBlock var streamingChunks []string + var finalUsage *model.AnthropicUsage + var messageID string + var modelName string + var stopReason string scanner := bufio.NewScanner(resp.Body) for scanner.Scan() { @@ -369,12 +290,74 @@ func (h *Handler) handleStreamingResponse(w http.ResponseWriter, resp *http.Resp } jsonData := strings.TrimPrefix(line, "data: ") - var event model.StreamingEvent - if err := json.Unmarshal([]byte(jsonData), &event); err != nil { + + // Parse as generic JSON first to capture usage data + var genericEvent map[string]interface{} + if err := json.Unmarshal([]byte(jsonData), &genericEvent); err != nil { log.Printf("⚠️ Error unmarshalling streaming event: %v", err) continue } + // Capture usage data and metadata from message_start event + if eventType, ok := genericEvent["type"].(string); ok && eventType == "message_start" { + if message, ok := genericEvent["message"].(map[string]interface{}); ok { + // Capture message metadata + if id, ok := message["id"].(string); ok { + messageID = id + } + if model, ok := message["model"].(string); ok { + modelName = model + } + if reason, ok := message["stop_reason"].(string); ok { + stopReason = reason + } + + // Capture initial usage data from message_start + if usage, ok := message["usage"].(map[string]interface{}); ok { + finalUsage = &model.AnthropicUsage{} + if inputTokens, ok := usage["input_tokens"].(float64); ok { + finalUsage.InputTokens = int(inputTokens) + } + if outputTokens, ok := usage["output_tokens"].(float64); ok { + finalUsage.OutputTokens = int(outputTokens) + } + if cacheCreation, ok := usage["cache_creation_input_tokens"].(float64); ok { + finalUsage.CacheCreationInputTokens = int(cacheCreation) + } + if cacheRead, ok := usage["cache_read_input_tokens"].(float64); ok { + finalUsage.CacheReadInputTokens = int(cacheRead) + } + if tier, ok := usage["service_tier"].(string); ok { + finalUsage.ServiceTier = tier + } + log.Printf("📊 Captured initial usage from message_start: %+v", finalUsage) + } else { + log.Printf("⚠️ No usage data found in message_start event") + } + } + } + + // Update output tokens from message_delta event + if eventType, ok := genericEvent["type"].(string); ok && eventType == "message_delta" { + // Usage is at top level for message_delta events + if usage, ok := genericEvent["usage"].(map[string]interface{}); ok { + if finalUsage != nil { + if outputTokens, ok := usage["output_tokens"].(float64); ok { + finalUsage.OutputTokens = int(outputTokens) + log.Printf("📊 Updated output tokens from message_delta: %d", int(outputTokens)) + } + } else { + log.Printf("⚠️ finalUsage is nil when trying to update from message_delta usage") + } + } + } + + // Parse as structured event for content processing + var event model.StreamingEvent + if err := json.Unmarshal([]byte(jsonData), &event); err != nil { + continue // Skip if structured parsing fails, but we already got the usage data above + } + switch event.Type { case "content_block_delta": if event.Delta != nil { @@ -391,8 +374,7 @@ func (h *Handler) handleStreamingResponse(w http.ResponseWriter, resp *http.Resp toolCalls = append(toolCalls, *event.ContentBlock) } case "message_stop": - // End of stream - break + // End of stream - scanner will exit on its own } } @@ -405,19 +387,41 @@ func (h *Handler) handleStreamingResponse(w http.ResponseWriter, resp *http.Resp CompletedAt: time.Now().Format(time.RFC3339), } - // Create a structured body for the log - var responseBody model.AnthropicMessage - responseBody.Role = "assistant" - var contentBlocks []model.ContentBlock + // Create a structured response body that matches Anthropic's format + var contentBlocks []model.AnthropicContentBlock if fullResponseText.Len() > 0 { - contentBlocks = append(contentBlocks, model.ContentBlock{ + contentBlocks = append(contentBlocks, model.AnthropicContentBlock{ Type: "text", Text: fullResponseText.String(), }) } - contentBlocks = append(contentBlocks, toolCalls...) - responseBody.Content = contentBlocks - responseLog.Body = responseBody + + // Create an AnthropicResponse-like structure for consistency + responseBody := map[string]interface{}{ + "content": contentBlocks, + "id": messageID, + "model": modelName, + "role": "assistant", + "stop_reason": stopReason, + "type": "message", + } + + // 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 + responseBodyBytes, err := json.Marshal(responseBody) + if err != nil { + log.Printf("❌ Error marshaling streaming response body: %v", err) + responseBodyBytes = []byte("{}") + } + + responseLog.Body = json.RawMessage(responseBodyBytes) requestLog.Response = responseLog if err := h.storageService.UpdateRequestWithResponse(requestLog); err != nil { @@ -432,6 +436,10 @@ 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) @@ -439,21 +447,35 @@ 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), - BodyText: string(responseBytes), ResponseTime: time.Since(startTime).Milliseconds(), IsStreaming: false, CompletedAt: time.Now().Format(time.RFC3339), } - // Try to parse as JSON for structured logging - if resp.Header.Get("Content-Type") == "application/json" { - var jsonBody interface{} - if json.Unmarshal(responseBytes, &jsonBody) == nil { - responseLog.Body = jsonBody + // Parse the response as AnthropicResponse for consistent structure + if resp.StatusCode == http.StatusOK { + var anthropicResp model.AnthropicResponse + 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) + log.Printf("📄 Response body (first 500 chars): %s", string(responseBytes[:min(500, len(responseBytes))])) + responseLog.BodyText = string(responseBytes) } + } else { + // For error responses, store as text + responseLog.BodyText = string(responseBytes) } requestLog.Response = responseLog @@ -474,6 +496,14 @@ func (h *Handler) handleNonStreamingResponse(w http.ResponseWriter, resp *http.R w.Write(responseBytes) } +// Helper function to get minimum of two integers +func min(a, b int) int { + if a < b { + return a + } + return b +} + func generateRequestID() string { bytes := make([]byte, 8) rand.Read(bytes) diff --git a/proxy/internal/model/models.go b/proxy/internal/model/models.go index b62e9a0..13d2fe4 100644 --- a/proxy/internal/model/models.go +++ b/proxy/internal/model/models.go @@ -41,7 +41,7 @@ type RequestLog struct { type ResponseLog struct { StatusCode int `json:"statusCode"` Headers map[string][]string `json:"headers"` - Body interface{} `json:"body,omitempty"` + Body json.RawMessage `json:"body,omitempty"` BodyText string `json:"bodyText,omitempty"` ResponseTime int64 `json:"responseTime"` StreamingChunks []string `json:"streamingChunks,omitempty"` @@ -60,25 +60,23 @@ type ChatCompletionRequest struct { Stream bool `json:"stream,omitempty"` } -type ChatCompletionResponse struct { - ID string `json:"id"` - Object string `json:"object"` - Created int64 `json:"created"` - Model string `json:"model"` - Choices []Choice `json:"choices"` - Usage Usage `json:"usage"` +type AnthropicUsage struct { + InputTokens int `json:"input_tokens"` + OutputTokens int `json:"output_tokens"` + CacheCreationInputTokens int `json:"cache_creation_input_tokens,omitempty"` + CacheReadInputTokens int `json:"cache_read_input_tokens,omitempty"` + ServiceTier string `json:"service_tier,omitempty"` } -type Choice struct { - Index int `json:"index"` - Message ChatMessage `json:"message"` - FinishReason string `json:"finish_reason"` -} - -type Usage struct { - PromptTokens int `json:"prompt_tokens"` - CompletionTokens int `json:"completion_tokens"` - TotalTokens int `json:"total_tokens"` +type AnthropicResponse struct { + Content []AnthropicContentBlock `json:"content"` + ID string `json:"id"` + Model string `json:"model"` + Role string `json:"role"` + StopReason string `json:"stop_reason"` + StopSequence *string `json:"stop_sequence"` + Type string `json:"type"` + Usage AnthropicUsage `json:"usage"` } type AnthropicContentBlock struct { @@ -179,7 +177,6 @@ type ErrorResponse struct { Details string `json:"details,omitempty"` } - type StreamingEvent struct { Type string `json:"type"` Index *int `json:"index,omitempty"` diff --git a/proxy/internal/service/anthropic.go b/proxy/internal/service/anthropic.go index a21cd2e..ae32aca 100644 --- a/proxy/internal/service/anthropic.go +++ b/proxy/internal/service/anthropic.go @@ -1,9 +1,8 @@ package service import ( - "bytes" + "compress/gzip" "context" - "encoding/json" "fmt" "io" "net/http" @@ -13,12 +12,10 @@ import ( "time" "github.com/seifghazi/claude-code-monitor/internal/config" - "github.com/seifghazi/claude-code-monitor/internal/model" ) type AnthropicService interface { - ForwardRequest(ctx context.Context, request *model.AnthropicRequest, apiKey string) (*http.Response, error) - GradePrompt(ctx context.Context, messages []model.AnthropicMessage, systemMessages []model.AnthropicSystemMessage, apiKey string) (*model.PromptGrade, error) + ForwardRequest(ctx context.Context, originalReq *http.Request) (*http.Response, error) } type anthropicService struct { @@ -29,263 +26,97 @@ type anthropicService struct { func NewAnthropicService(cfg *config.AnthropicConfig) AnthropicService { return &anthropicService{ client: &http.Client{ - Timeout: 60 * time.Second, + Timeout: 300 * time.Second, // Increased timeout to 5 minutes }, config: cfg, } } -func (s *anthropicService) ForwardRequest(ctx context.Context, request *model.AnthropicRequest, apiKey string) (*http.Response, error) { - if apiKey == "" { - return nil, fmt.Errorf("API key not provided") - } - - requestBody, err := json.Marshal(request) - if err != nil { - return nil, fmt.Errorf("failed to marshal request: %w", err) - } - - if s.config.BaseURL == "" { - return nil, fmt.Errorf("anthropic base URL is not configured. Please set ANTHROPIC_BASE_URL") - } +func (s *anthropicService) ForwardRequest(ctx context.Context, originalReq *http.Request) (*http.Response, error) { + // Clone the request to avoid modifying the original + proxyReq := originalReq.Clone(ctx) + // Parse the configured base URL baseURL, err := url.Parse(s.config.BaseURL) if err != nil { - return nil, fmt.Errorf("failed to parse anthropic base URL '%s': %w", s.config.BaseURL, err) + return nil, fmt.Errorf("failed to parse base URL '%s': %w", s.config.BaseURL, err) } if baseURL.Scheme == "" || baseURL.Host == "" { - return nil, fmt.Errorf("invalid anthropic base URL, scheme and host are required: %s", s.config.BaseURL) + return nil, fmt.Errorf("invalid base URL, scheme and host are required: %s", s.config.BaseURL) } - baseURL.Path = path.Join(baseURL.Path, "/v1/messages") - fullURL := baseURL.String() + // Update the destination URL + proxyReq.URL.Scheme = baseURL.Scheme + proxyReq.URL.Host = baseURL.Host + proxyReq.URL.Path = path.Join(baseURL.Path, "/v1/messages") - req, err := http.NewRequestWithContext(ctx, "POST", fullURL, bytes.NewBuffer(requestBody)) - if err != nil { - return nil, fmt.Errorf("failed to create request: %w", err) - } + // Preserve query parameters from original request + proxyReq.URL.RawQuery = originalReq.URL.RawQuery - req.Header.Set("Content-Type", "application/json") - req.Header.Set("x-api-key", apiKey) - req.Header.Set("anthropic-version", s.config.Version) + // Clear fields that can't be set in client requests + proxyReq.RequestURI = "" // This is set by the server and must be cleared + proxyReq.Host = "" // Let Go set this from the URL - resp, err := s.client.Do(req) + // Forward the request with all original headers intact + resp, err := s.client.Do(proxyReq) if err != nil { return nil, fmt.Errorf("failed to send request: %w", err) } + // Handle gzip decompression + if strings.Contains(resp.Header.Get("Content-Encoding"), "gzip") { + decompressedResp, err := s.decompressGzipResponse(resp) + if err != nil { + resp.Body.Close() + return nil, fmt.Errorf("failed to decompress gzip response: %w", err) + } + return decompressedResp, nil + } + return resp, nil } -func (s *anthropicService) GradePrompt(ctx context.Context, messages []model.AnthropicMessage, systemMessages []model.AnthropicSystemMessage, apiKey string) (*model.PromptGrade, error) { - if apiKey == "" { - return nil, fmt.Errorf("API key not provided") - } - - userContentParts := s.extractUserContent(messages) - if len(userContentParts) == 0 { - return nil, fmt.Errorf("no user content found to grade") - } - - originalPrompt := strings.Join(userContentParts, "\n\n") - systemPrompt := s.extractSystemPrompt(systemMessages) - - gradingPrompt := s.buildGradingPrompt(originalPrompt, systemPrompt) - - claudeRequest := &model.AnthropicRequest{ - Model: "claude-3-5-sonnet-20240620", - MaxTokens: 4000, - Messages: []model.AnthropicMessage{ - { - Role: "user", - Content: gradingPrompt, - }, - }, - } - - resp, err := s.ForwardRequest(ctx, claudeRequest, apiKey) +func (s *anthropicService) decompressGzipResponse(resp *http.Response) (*http.Response, error) { + // Create a gzip reader + gzipReader, err := gzip.NewReader(resp.Body) if err != nil { - return nil, fmt.Errorf("failed to send grading request: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - bodyBytes, _ := io.ReadAll(resp.Body) - return nil, fmt.Errorf("API request failed with status %d: %s", resp.StatusCode, string(bodyBytes)) + return nil, fmt.Errorf("failed to create gzip reader: %w", err) } - var claudeResponse struct { - Content []struct { - Type string `json:"type"` - Text string `json:"text"` - } `json:"content"` + // Read the decompressed data + decompressedData, err := io.ReadAll(gzipReader) + if err != nil { + gzipReader.Close() + return nil, fmt.Errorf("failed to read decompressed data: %w", err) } - if err := json.NewDecoder(resp.Body).Decode(&claudeResponse); err != nil { - return nil, fmt.Errorf("failed to decode response: %w", err) + // Close the gzip reader and original body + gzipReader.Close() + resp.Body.Close() + + // Create a new response with decompressed body + newResp := &http.Response{ + Status: resp.Status, + StatusCode: resp.StatusCode, + Proto: resp.Proto, + ProtoMajor: resp.ProtoMajor, + ProtoMinor: resp.ProtoMinor, + Header: resp.Header.Clone(), + ContentLength: int64(len(decompressedData)), + TransferEncoding: resp.TransferEncoding, + Close: resp.Close, + Uncompressed: true, + Trailer: resp.Trailer, + Request: resp.Request, + TLS: resp.TLS, } - if len(claudeResponse.Content) == 0 { - return nil, fmt.Errorf("empty response from Claude") - } + // Remove Content-Encoding header since we've decompressed + newResp.Header.Del("Content-Encoding") - return s.parseGradingResponse(claudeResponse.Content[0].Text) -} - -func (s *anthropicService) extractUserContent(messages []model.AnthropicMessage) []string { - var userContentParts []string - for _, msg := range messages { - if msg.Role == "user" { - blocks := msg.GetContentBlocks() - for _, block := range blocks { - if block.Type == "text" { - text := strings.TrimSpace(block.Text) - if text != "" && !s.isSystemReminder(text) { - userContentParts = append(userContentParts, text) - } - } - } - } - } - return userContentParts -} - -func (s *anthropicService) extractSystemPrompt(systemMessages []model.AnthropicSystemMessage) string { - var systemPromptParts []string - for _, msg := range systemMessages { - if msg.Text != "" { - systemPromptParts = append(systemPromptParts, msg.Text) - } - } - systemPrompt := strings.Join(systemPromptParts, "\n\n") - if systemPrompt == "" { - systemPrompt = "No system prompt was provided for this request." - } - return systemPrompt -} - -func (s *anthropicService) isSystemReminder(text string) bool { - text = strings.TrimSpace(text) - lowerText := strings.ToLower(text) - - systemPatterns := []string{ - "", - "system-reminder>", - "this is a reminder that your todo list", - "as you answer the user's questions, you can use the following context:", - "important-instruction-reminders", - "do not mention this to the user explicitly", - "the user opened the file", - "the user selected the following lines", - "caveat: the messages below were generated by the user while running local commands", - } - - for _, pattern := range systemPatterns { - if strings.Contains(lowerText, strings.ToLower(pattern)) { - return true - } - } - - return false -} - -func (s *anthropicService) buildGradingPrompt(originalPrompt, systemPrompt string) string { - return fmt.Sprintf(` -You are an expert prompt engineer specializing in Anthropic's Claude best practices. Please analyze the following user prompt and provide a comprehensive grading report. - - -%s - - -For context, here is the system prompt used in this request: - -%s - - -Please evaluate this prompt across these 5 criteria and provide your analysis in the exact JSON format specified below: - -1. **Clarity & Explicitness** (1-5): How clear and specific are the instructions? -2. **Context & Motivation** (1-5): Does it explain why the task matters and provide sufficient background? -3. **Structure & Format** (1-5): Is it well-organized? Does it use XML tags effectively? -4. **Examples & Details** (1-5): Are there sufficient examples and detailed specifications? -5. **Task-Specific Best Practices** (1-5): Does it follow Claude-specific best practices (thinking prompts, role specification, etc.)? - -Additionally, create an improved version of this prompt that addresses any weaknesses you identify. Include XML tags to structure the output if necessary. - - - -Please respond with a JSON object in exactly this format: -{ - "overallScore": [1-5 integer], - "detailedFeedback": "[comprehensive analysis of the prompt's strengths and weaknesses]", - "improvedPrompt": "[your rewritten version of the prompt that addresses the issues]", - "criteria": { - "clarity": { - "score": [1-5 integer], - "feedback": "[specific feedback for clarity]" - }, - "context": { - "score": [1-5 integer], - "feedback": "[specific feedback for context]" - }, - "structure": { - "score": [1-5 integer], - "feedback": "[specific feedback for structure]" - }, - "examples": { - "score": [1-5 integer], - "feedback": "[specific feedback for examples]" - }, - "taskSpecific": { - "score": [1-5 integer], - "feedback": "[specific feedback for task-specific practices]" - } - } -} -`, originalPrompt, systemPrompt) -} - -func (s *anthropicService) parseGradingResponse(responseText string) (*model.PromptGrade, error) { - var jsonStr string - - if strings.Contains(responseText, "```json") { - start := strings.Index(responseText, "```json") + 7 - end := strings.Index(responseText[start:], "```") - if end != -1 { - jsonStr = strings.TrimSpace(responseText[start : start+end]) - } - } else { - jsonStart := strings.Index(responseText, "{") - jsonEnd := strings.LastIndex(responseText, "}") - if jsonStart == -1 || jsonEnd == -1 { - return nil, fmt.Errorf("no JSON found in Claude's response") - } - jsonStr = responseText[jsonStart : jsonEnd+1] - } - - if jsonStr == "" { - return nil, fmt.Errorf("no JSON found in Claude's response") - } - - var gradingResult struct { - OverallScore int `json:"overallScore"` - DetailedFeedback string `json:"detailedFeedback"` - ImprovedPrompt string `json:"improvedPrompt"` - Criteria map[string]model.CriteriaScore `json:"criteria"` - } - - if err := json.Unmarshal([]byte(jsonStr), &gradingResult); err != nil { - return nil, fmt.Errorf("failed to parse grading result: %w", err) - } - - return &model.PromptGrade{ - Score: gradingResult.OverallScore, - MaxScore: 5, - Feedback: gradingResult.DetailedFeedback, - ImprovedPrompt: gradingResult.ImprovedPrompt, - Criteria: gradingResult.Criteria, - GradingTimestamp: time.Now().Format(time.RFC3339), - IsProcessing: false, - }, nil + // Set the decompressed body + newResp.Body = io.NopCloser(strings.NewReader(string(decompressedData))) + + return newResp, nil } diff --git a/proxy/internal/service/storage_sqlite.go b/proxy/internal/service/storage_sqlite.go index 8033ce1..ec820b4 100644 --- a/proxy/internal/service/storage_sqlite.go +++ b/proxy/internal/service/storage_sqlite.go @@ -73,15 +73,6 @@ func (s *sqliteStorageService) SaveRequest(request *model.RequestLog) (string, e return "", fmt.Errorf("failed to marshal body: %w", err) } - // Extract model from body if available - var modelName string - if body, ok := request.Body.(map[string]interface{}); ok { - if model, ok := body["model"].(string); ok { - modelName = model - request.Model = model // Also set it in the struct - } - } - query := ` INSERT INTO requests (id, timestamp, method, endpoint, headers, body, user_agent, content_type, model) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) @@ -96,7 +87,7 @@ func (s *sqliteStorageService) SaveRequest(request *model.RequestLog) (string, e string(bodyJSON), request.UserAgent, request.ContentType, - modelName, + request.Model, ) if err != nil { diff --git a/web/app/routes/_index.tsx b/web/app/routes/_index.tsx index 2f34a1e..009ac5e 100644 --- a/web/app/routes/_index.tsx +++ b/web/app/routes/_index.tsx @@ -68,7 +68,16 @@ interface Request { response?: { statusCode: number; headers: Record; - body?: any; + body?: { + usage?: { + input_tokens?: number; + output_tokens?: number; + cache_creation_input_tokens?: number; + cache_read_input_tokens?: number; + service_tier?: string; + }; + [key: string]: any; + }; bodyText?: string; responseTime: number; streamingChunks?: string[]; @@ -222,17 +231,13 @@ export default function Index() { } }; - const loadConversations = async (filter?: string, loadMore = false) => { + const loadConversations = async (loadMore = false) => { setIsFetching(true); const pageToFetch = loadMore ? conversationsCurrentPage + 1 : 1; try { - const currentModelFilter = filter || modelFilter; const url = new URL('/api/conversations', window.location.origin); url.searchParams.append("page", pageToFetch.toString()); url.searchParams.append("limit", itemsPerPage.toString()); - if (currentModelFilter !== "all") { - url.searchParams.append("model", currentModelFilter); - } const response = await fetch(url.toString()); if (!response.ok) { @@ -322,39 +327,39 @@ export default function Index() { }; const getRequestSummary = (request: Request) => { - if (request.body?.messages) { - const messageCount = request.body.messages.length; + const parts = []; + + // Add token usage if available + if (request.response?.body?.usage) { + const usage = request.response.body.usage; + const inputTokens = usage.input_tokens || 0; + const outputTokens = usage.output_tokens || 0; + const totalTokens = inputTokens + outputTokens; - // Count tool calls - const toolCalls = request.body.messages.reduce((count, msg) => { - if (msg.content && Array.isArray(msg.content)) { - return count + msg.content.filter((c: any) => c.type === 'tool_use').length; + if (totalTokens > 0) { + parts.push(`🪙 ${totalTokens.toLocaleString()} tokens`); + + if (usage.cache_read_input_tokens) { + parts.push(`💾 ${usage.cache_read_input_tokens.toLocaleString()} cached`); } - return count; - }, 0); - - // Count tool definitions in system prompt - let toolDefinitions = 0; - if (request.body.system) { - request.body.system.forEach(sys => { - if (sys.text && sys.text.includes('')) { - const functionMatches = [...sys.text.matchAll(/([\s\S]*?)<\/function>/g)]; - toolDefinitions += functionMatches.length; - } - }); } - - let summary = `💬 ${messageCount} messages`; - if (toolDefinitions > 0) { - summary += ` • 🛠️ ${toolDefinitions} tools available`; - } - if (toolCalls > 0) { - summary += ` • ⚡ ${toolCalls} tool calls executed`; - } - - return summary; } - return '📡 API request'; + + // Add response time if available + if (request.response?.responseTime) { + const seconds = (request.response.responseTime / 1000).toFixed(1); + parts.push(`⏱️ ${seconds}s`); + } + + // Add model if available + if (request.body?.model) { + const modelShort = request.body.model.includes('opus') ? 'Opus' : + request.body.model.includes('sonnet') ? 'Sonnet' : + request.body.model.includes('haiku') ? 'Haiku' : 'Model'; + parts.push(`🤖 ${modelShort}`); + } + + return parts.length > 0 ? parts.join(' • ') : '📡 API request'; }; const showRequestDetails = (requestId: number) => { @@ -495,7 +500,7 @@ export default function Index() { if (viewMode === 'requests') { loadRequests(modelFilter); } else { - loadConversations(modelFilter); + loadConversations(); } }, [viewMode, modelFilter]); @@ -504,139 +509,122 @@ export default function Index() { return (
{/* Header */} -
-
+
+
-
-
-
- -
-
-

Claude Code Monitor

-
-
-
-
- - -
+

Claude Code Monitor

+
+
+ +
- {/* Filter buttons */} -
-
- - - - -
-
- {/* View mode toggle */} -
-
+
+
+ {/* Filter buttons - only show for requests view */} + {viewMode === "requests" && ( +
+
+ + + + +
+
+ )} + {/* Main Content */}
{/* Stats Grid */} -
-
+
+
-
-

+

+

{viewMode === "requests" ? "Total Requests" : "Total Conversations"}

-

+

{viewMode === "requests" ? requests.length : conversations.length}

- {/*

All time

*/} -
-
- {viewMode === "requests" ? ( - - ) : ( - - )}
@@ -645,108 +633,101 @@ export default function Index() { {/* Main Content */} {viewMode === "requests" ? ( /* Request History */ -
-
+
+
-
- -

Request History

-
- {/*
- -
*/} +

Request History

{(isFetching && requestsCurrentPage === 1) || isPending ? ( -
- -

Loading requests...

+
+ +

Loading requests...

) : filteredRequests.length === 0 ? ( -
-
- -
-

No requests found

-

Make sure you have set the ANTHROPIC_BASE_URL environment variable to the proxy server URL

+
+

No requests found

+

Make sure you have set ANTHROPIC_BASE_URL to point at the proxy

) : ( <> {filteredRequests.map(request => ( -
showRequestDetails(request.id)}> -
-
- - {request.method} - -
-
- {request.endpoint} - {request.conversationId && ( - - Turn {request.turnNumber} +
showRequestDetails(request.id)}> +
+
+ {/* Model and Status */} +
+

+ {request.body?.model ? ( + request.body.model.includes('opus') ? Opus : + request.body.model.includes('sonnet') ? Sonnet : + request.body.model.includes('haiku') ? Haiku : + {request.body.model.split('-')[0]} + ) : API} +

+ {request.response?.statusCode && ( + = 200 && request.response.statusCode < 300 + ? 'bg-green-100 text-green-700' + : request.response.statusCode >= 300 && request.response.statusCode < 400 + ? 'bg-yellow-100 text-yellow-700' + : 'bg-red-100 text-red-700' + }`}> + {request.response.statusCode} + + )} + {request.conversationId && ( + + Turn {request.turnNumber} + + )} +
+ + {/* Endpoint */} +
+ {request.endpoint} +
+ + {/* Metrics Row */} +
+ {request.response?.body?.usage && ( + <> + + {((request.response.body.usage.input_tokens || 0) + (request.response.body.usage.output_tokens || 0)).toLocaleString()} tokens - )} -
- {new Date(request.timestamp).toLocaleString()} + {request.response.body.usage.cache_read_input_tokens && ( + + {request.response.body.usage.cache_read_input_tokens.toLocaleString()} cached + + )} + + )} + + {request.response?.responseTime && ( + + {(request.response.responseTime / 1000).toFixed(2)}s + + )}
-
- {request.body?.model && ( - - {request.body.model} - - )} - {/* {request.promptGrade ? ( - = 4 - ? 'bg-green-50 border-green-200 text-green-700' - : request.promptGrade.score >= 3 - ? 'bg-yellow-50 border-yellow-200 text-yellow-700' - : 'bg-red-50 border-red-200 text-red-700' - }`}> - {request.promptGrade.score >= 4 ? '🎉' : request.promptGrade.score >= 3 ? '👍' : '⚠️'} {request.promptGrade.score}/5 - - ) : ( - canGradeRequest(request) && ( - - ) - )} */} -
- +
+
+ {new Date(request.timestamp).toLocaleDateString()} +
+
+ {new Date(request.timestamp).toLocaleTimeString()}
-
- {getRequestSummary(request)} -
))} {hasMoreRequests && ( -
+
@@ -758,73 +739,77 @@ export default function Index() {
) : ( /* Conversations View */ -
-
-
-
- -

Conversations

-
-
+
+
+

Conversations

{(isFetching && conversationsCurrentPage === 1) || isPending ? ( -
- -

Loading conversations...

+
+ +

Loading conversations...

) : conversations.length === 0 ? ( -
-
- -
-

No conversations found

-

Start a conversation to see it appear here

+
+

No conversations found

+

Start a conversation to see it appear here

) : ( <> {conversations.map(conversation => ( -
loadConversationDetails(conversation.id, conversation.projectName)}> -
-
-
- -
-
- Conversation {conversation.id.slice(-8)} - {new Date(conversation.startTime).toLocaleString()} +
loadConversationDetails(conversation.id, conversation.projectName)}> +
+
+
+ + #{conversation.id.slice(-8)} + + + {conversation.requestCount} turns + + + {formatDuration(conversation.duration)} + {conversation.projectName && ( - {conversation.projectName} + + {conversation.projectName} + + )} +
+
+
+
First Message
+
+ {conversation.firstMessage || "No content"} +
+
+ {conversation.lastMessage && conversation.lastMessage !== conversation.firstMessage && ( +
+
Latest Message
+
+ {conversation.lastMessage} +
+
)}
-
- - {conversation.requestCount} turns - -
- +
+
+ {new Date(conversation.startTime).toLocaleDateString()} +
+
+ {new Date(conversation.startTime).toLocaleTimeString()}
-
-
- First: {conversation.firstMessage.substring(0, 200) || "No content"}{conversation.firstMessage.length > 200 && "..."} -
- {conversation.lastMessage && conversation.lastMessage !== conversation.firstMessage && ( -
- Latest: {conversation.lastMessage.substring(0, 200)}{conversation.lastMessage.length > 200 && "..."} -
- )} -
))} {hasMoreConversations && ( -
+
diff --git a/web/app/tailwind.css b/web/app/tailwind.css index 8d76c65..7374977 100644 --- a/web/app/tailwind.css +++ b/web/app/tailwind.css @@ -2,170 +2,39 @@ @tailwind components; @tailwind utilities; -@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap'); - * { - font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; } body { - background-color: #f9fafb; - color: #101828; + background-color: #fafafa; + color: #111; } -.card { - background: white; - border: 1px solid #eaecf0; - border-radius: 12px; - box-shadow: 0 1px 3px 0 rgba(16, 24, 40, 0.1), 0 1px 2px 0 rgba(16, 24, 40, 0.06); +@layer utilities { + .line-clamp-2 { + overflow: hidden; + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; + } } -.card-hover { - transition: all 0.2s ease; -} -.card-hover:hover { - box-shadow: 0 4px 6px -1px rgba(16, 24, 40, 0.1), 0 2px 4px -1px rgba(16, 24, 40, 0.06); - border-color: #d0d5dd; -} - -.stat-card { - background: white; - border: 1px solid #eaecf0; - border-radius: 12px; - box-shadow: 0 1px 3px 0 rgba(16, 24, 40, 0.1), 0 1px 2px 0 rgba(16, 24, 40, 0.06); -} - -.nav-button { - background: white; - border: 1px solid #d0d5dd; - color: #344054; - font-weight: 500; - transition: all 0.2s ease; -} - -.nav-button:hover { - background: #f9fafb; - border-color: #98a2b3; -} - -.nav-button-primary { - background: #3b82f6; - border: 1px solid #3b82f6; - color: white; - font-weight: 500; -} - -.nav-button-primary:hover { - background: #2563eb; - border-color: #2563eb; -} - -.nav-button-danger { - color: #dc2626; - border-color: #fecaca; - background: #fef2f2; -} - -.nav-button-danger:hover { - background: #fee2e2; - border-color: #fca5a5; -} - -.request-card { - background: white; - border: 1px solid #eaecf0; - border-radius: 8px; - transition: all 0.2s ease; -} - -.request-card:hover { - border-color: #3b82f6; - box-shadow: 0 4px 6px -1px rgba(16, 24, 40, 0.1), 0 2px 4px -1px rgba(16, 24, 40, 0.06); -} - -.modal-overlay { - background: rgba(16, 24, 40, 0.7); - backdrop-filter: blur(8px); -} - -.modal-content { - background: white; - border: 1px solid #eaecf0; - border-radius: 12px; - box-shadow: 0 25px 50px -12px rgba(16, 24, 40, 0.25); -} - -.method-badge { - font-weight: 600; - font-size: 0.75rem; - letter-spacing: 0.025em; - text-transform: uppercase; - border-radius: 6px; - padding: 4px 8px; -} .code-block { - font-family: 'SF Mono', 'Monaco', 'Cascadia Code', monospace; - font-size: 0.875rem; - line-height: 1.6; - background: #f9fafb; - border: 1px solid #eaecf0; - border-radius: 8px; + font-family: 'SF Mono', Monaco, Consolas, 'Courier New', monospace; + font-size: 0.75rem; + line-height: 1.5; + background: #f5f5f5; + border: 1px solid #e5e5e5; + border-radius: 4px; } -.status-indicator { - width: 8px; - height: 8px; - border-radius: 50%; - background: #10b981; - animation: pulse 2s infinite; -} - -@keyframes pulse { - 0%, 100% { opacity: 1; } - 50% { opacity: 0.7; } -} - -.header-blur { - background: rgba(255, 255, 255, 0.8); - backdrop-filter: blur(20px); - border-bottom: 1px solid #eaecf0; -} - -.section-header { - background: #f9fafb; - border-bottom: 1px solid #eaecf0; -} - -.message-role-user { - background: #eff6ff; - border: 1px solid #dbeafe; - border-left: 3px solid #3b82f6; -} - -.message-role-assistant { - background: #f8fafc; - border: 1px solid #e2e8f0; - border-left: 3px solid #64748b; -} - -.message-role-system { - background: #fffbeb; - border: 1px solid #fef3c7; - border-left: 3px solid #f59e0b; -} - -.tool-badge { - background: #ecfdf5; - border: 1px solid #bbf7d0; - color: #047857; - font-weight: 500; -} .scrollbar-custom { scrollbar-width: thin; - scrollbar-color: #cbd5e1 #f1f5f9; + scrollbar-color: #ddd #f5f5f5; } .scrollbar-custom::-webkit-scrollbar { @@ -174,65 +43,16 @@ body { } .scrollbar-custom::-webkit-scrollbar-track { - background: #f1f5f9; + background: #f5f5f5; border-radius: 3px; } .scrollbar-custom::-webkit-scrollbar-thumb { - background: #cbd5e1; + background: #ddd; border-radius: 3px; } .scrollbar-custom::-webkit-scrollbar-thumb:hover { - background: #94a3b8; + background: #ccc; } -.scrollbar-dark { - scrollbar-width: thin; - scrollbar-color: #4b5563 #374151; -} - -.scrollbar-dark::-webkit-scrollbar { - width: 6px; - height: 6px; -} - -.scrollbar-dark::-webkit-scrollbar-track { - background: #374151; - border-radius: 3px; -} - -.scrollbar-dark::-webkit-scrollbar-thumb { - background: #4b5563; - border-radius: 3px; -} - -.scrollbar-dark::-webkit-scrollbar-thumb:hover { - background: #6b7280; -} - -select:focus { - outline: none; - border-color: #3b82f6; - box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1); -} - -.text-primary { - color: #101828; -} - -.text-secondary { - color: #475467; -} - -.text-tertiary { - color: #667085; -} - -.icon-primary { - color: #475467; -} - -.icon-accent { - color: #3b82f6; -}