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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ node_modules/
# Database and logs
requests/*
requests.db
requests.db-shm
requests.db-wal
*.log
proxy.log

Expand Down Expand Up @@ -39,7 +41,8 @@ coverage/
# Temporary files
tmp/
temp/
.playwright-mcp/


# Config
config.yaml
config.yaml
5 changes: 5 additions & 0 deletions proxy/cmd/proxy/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,11 @@ func main() {
r.HandleFunc("/", h.UI).Methods("GET")
r.HandleFunc("/ui", h.UI).Methods("GET")
r.HandleFunc("/api/requests", h.GetRequests).Methods("GET")
r.HandleFunc("/api/requests/summary", h.GetRequestsSummary).Methods("GET")
r.HandleFunc("/api/requests/{id}", h.GetRequestByID).Methods("GET")
r.HandleFunc("/api/stats", h.GetStats).Methods("GET")
r.HandleFunc("/api/stats/hourly", h.GetHourlyStats).Methods("GET")
r.HandleFunc("/api/stats/models", h.GetModelStats).Methods("GET")
r.HandleFunc("/api/requests", h.DeleteRequests).Methods("DELETE")
r.HandleFunc("/api/conversations", h.GetConversations).Methods("GET")
r.HandleFunc("/api/conversations/{id}", h.GetConversationByID).Methods("GET")
Expand Down
149 changes: 149 additions & 0 deletions proxy/internal/handler/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,155 @@ func (h *Handler) GetRequests(w http.ResponseWriter, r *http.Request) {
})
}

// GetRequestsSummary returns lightweight request data for fast list rendering
func (h *Handler) GetRequestsSummary(w http.ResponseWriter, r *http.Request) {
modelFilter := r.URL.Query().Get("model")
if modelFilter == "" {
modelFilter = "all"
}

// Get start/end time range (UTC ISO 8601 format from browser)
startTime := r.URL.Query().Get("start")
endTime := r.URL.Query().Get("end")

// Parse pagination params
offset := 0
limit := 0 // Default to 0 (no limit - fetch all)

if offsetStr := r.URL.Query().Get("offset"); offsetStr != "" {
if parsed, err := strconv.Atoi(offsetStr); err == nil && parsed >= 0 {
offset = parsed
}
}

if limitStr := r.URL.Query().Get("limit"); limitStr != "" {
if parsed, err := strconv.Atoi(limitStr); err == nil && parsed > 0 && parsed <= 100000 {
limit = parsed
}
}

summaries, total, err := h.storageService.GetRequestsSummaryPaginated(modelFilter, startTime, endTime, offset, limit)
if err != nil {
log.Printf("Error getting request summaries: %v", err)
http.Error(w, "Failed to get requests", http.StatusInternalServerError)
return
}

w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(struct {
Requests []*model.RequestSummary `json:"requests"`
Total int `json:"total"`
Offset int `json:"offset"`
Limit int `json:"limit"`
}{
Requests: summaries,
Total: total,
Offset: offset,
Limit: limit,
})
}

// GetRequestByID returns a single request by its ID
func (h *Handler) GetRequestByID(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
requestID := vars["id"]

if requestID == "" {
http.Error(w, "Request ID is required", http.StatusBadRequest)
return
}

request, fullID, err := h.storageService.GetRequestByShortID(requestID)
if err != nil {
log.Printf("Error getting request by ID %s: %v", requestID, err)
http.Error(w, "Failed to get request", http.StatusInternalServerError)
return
}

if request == nil {
http.Error(w, "Request not found", http.StatusNotFound)
return
}

w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(struct {
Request *model.RequestLog `json:"request"`
FullID string `json:"fullId"`
}{
Request: request,
FullID: fullID,
})
}

// GetStats returns aggregated dashboard statistics - lightning fast!
func (h *Handler) GetStats(w http.ResponseWriter, r *http.Request) {
// Get start/end time range (UTC ISO 8601 format from browser)
// Browser sends the user's local day boundaries converted to UTC
startTime := r.URL.Query().Get("start")
endTime := r.URL.Query().Get("end")

// Fallback to last 7 days if not provided
if startTime == "" || endTime == "" {
now := time.Now().UTC()
endTime = now.Format(time.RFC3339)
startTime = now.AddDate(0, 0, -7).Format(time.RFC3339)
}

stats, err := h.storageService.GetStats(startTime, endTime)
if err != nil {
log.Printf("Error getting stats: %v", err)
http.Error(w, "Failed to get stats", http.StatusInternalServerError)
return
}

w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(stats)
}

// GetHourlyStats returns hourly breakdown for a specific date range
func (h *Handler) GetHourlyStats(w http.ResponseWriter, r *http.Request) {
// Get start/end time range (UTC ISO 8601 format from browser)
startTime := r.URL.Query().Get("start")
endTime := r.URL.Query().Get("end")

if startTime == "" || endTime == "" {
http.Error(w, "start and end parameters are required", http.StatusBadRequest)
return
}

stats, err := h.storageService.GetHourlyStats(startTime, endTime)
if err != nil {
log.Printf("Error getting hourly stats: %v", err)
http.Error(w, "Failed to get hourly stats", http.StatusInternalServerError)
return
}

w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(stats)
}

// GetModelStats returns model breakdown for a specific date range
func (h *Handler) GetModelStats(w http.ResponseWriter, r *http.Request) {
// Get start/end time range (UTC ISO 8601 format from browser)
startTime := r.URL.Query().Get("start")
endTime := r.URL.Query().Get("end")

if startTime == "" || endTime == "" {
http.Error(w, "start and end parameters are required", http.StatusBadRequest)
return
}

stats, err := h.storageService.GetModelStats(startTime, endTime)
if err != nil {
log.Printf("Error getting model stats: %v", err)
http.Error(w, "Failed to get model stats", http.StatusInternalServerError)
return
}

w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(stats)
}

func (h *Handler) DeleteRequests(w http.ResponseWriter, r *http.Request) {

clearedCount, err := h.storageService.ClearRequests()
Expand Down
55 changes: 55 additions & 0 deletions proxy/internal/model/models.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,20 @@ type RequestLog struct {
Response *ResponseLog `json:"response,omitempty"`
}

// RequestSummary is a lightweight version of RequestLog for list views
type RequestSummary struct {
RequestID string `json:"requestId"`
Timestamp string `json:"timestamp"`
Method string `json:"method"`
Endpoint string `json:"endpoint"`
Model string `json:"model,omitempty"`
OriginalModel string `json:"originalModel,omitempty"`
RoutedModel string `json:"routedModel,omitempty"`
StatusCode int `json:"statusCode,omitempty"`
ResponseTime int64 `json:"responseTime,omitempty"`
Usage *AnthropicUsage `json:"usage,omitempty"`
}

type ResponseLog struct {
StatusCode int `json:"statusCode"`
Headers map[string][]string `json:"headers"`
Expand Down Expand Up @@ -196,3 +210,44 @@ type ContentBlock struct {
Input json.RawMessage `json:"input,omitempty"`
Text string `json:"text,omitempty"`
}

// Dashboard stats structures
type DashboardStats struct {
DailyStats []DailyTokens `json:"dailyStats"`
}

type HourlyStatsResponse struct {
HourlyStats []HourlyTokens `json:"hourlyStats"`
TodayTokens int64 `json:"todayTokens"`
TodayRequests int `json:"todayRequests"`
AvgResponseTime int64 `json:"avgResponseTime"`
}

type ModelStatsResponse struct {
ModelStats []ModelTokens `json:"modelStats"`
}

type DailyTokens struct {
Date string `json:"date"`
Tokens int64 `json:"tokens"`
Requests int `json:"requests"`
Models map[string]ModelStats `json:"models,omitempty"` // Per-model breakdown
}

type HourlyTokens struct {
Hour int `json:"hour"`
Tokens int64 `json:"tokens"`
Requests int `json:"requests"`
Models map[string]ModelStats `json:"models,omitempty"` // Per-model breakdown
}

type ModelStats struct {
Tokens int64 `json:"tokens"`
Requests int `json:"requests"`
}

type ModelTokens struct {
Model string `json:"model"`
Tokens int64 `json:"tokens"`
Requests int `json:"requests"`
}
74 changes: 13 additions & 61 deletions proxy/internal/service/model_router.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,6 @@ type ModelRouter struct {
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
}

Expand All @@ -42,7 +41,6 @@ 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,
}

Expand All @@ -58,62 +56,6 @@ func NewModelRouter(cfg *config.Config, providers map[string]provider.Provider,
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-5 family
"gpt-5", "gpt-5-mini", "gpt-5-nano",

// 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-1-20250805",
"claude-opus-4-20250514",
"claude-sonnet-4-20250514",
"claude-sonnet-4-5-20250929",
"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
Expand Down Expand Up @@ -264,11 +206,21 @@ func (r *ModelRouter) hashString(s string) string {
}

func (r *ModelRouter) getProviderNameForModel(model string) string {
if provider, exists := r.modelProviderMap[model]; exists {
return provider
modelLower := strings.ToLower(model)

// Anthropic models: claude-*
if strings.HasPrefix(modelLower, "claude") {
return "anthropic"
}

// OpenAI models: gpt-*, o1*, o3*
if strings.HasPrefix(modelLower, "gpt") ||
strings.HasPrefix(modelLower, "o1") ||
strings.HasPrefix(modelLower, "o3") {
return "openai"
}

// Default to anthropic
// Default to anthropic for unknown models
r.logger.Printf("⚠️ Model '%s' doesn't match any known patterns, defaulting to anthropic", model)
return "anthropic"
}
5 changes: 5 additions & 0 deletions proxy/internal/service/storage.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,9 @@ type StorageService interface {
GetRequestByShortID(shortID string) (*model.RequestLog, string, error)
GetConfig() *config.StorageConfig
GetAllRequests(modelFilter string) ([]*model.RequestLog, error)
GetRequestsSummary(modelFilter string) ([]*model.RequestSummary, error)
GetRequestsSummaryPaginated(modelFilter, startTime, endTime string, offset, limit int) ([]*model.RequestSummary, int, error)
GetStats(startDate, endDate string) (*model.DashboardStats, error)
GetHourlyStats(startTime, endTime string) (*model.HourlyStatsResponse, error)
GetModelStats(startTime, endTime string) (*model.ModelStatsResponse, error)
}
Loading