temp
This commit is contained in:
parent
9cb513019d
commit
80a25f7ba7
17 changed files with 1566 additions and 28 deletions
15
Makefile
15
Makefile
|
|
@ -1,4 +1,4 @@
|
|||
.PHONY: all build run clean install dev
|
||||
.PHONY: all build run clean install dev test test-proxy test-coverage
|
||||
|
||||
# Default target
|
||||
all: install build
|
||||
|
|
@ -43,6 +43,19 @@ clean:
|
|||
rm -f requests.db
|
||||
rm -rf requests/
|
||||
|
||||
# Testing
|
||||
test: test-proxy
|
||||
|
||||
test-proxy:
|
||||
@echo "🧪 Running proxy tests..."
|
||||
cd proxy && go test -v ./...
|
||||
|
||||
test-coverage:
|
||||
@echo "📊 Running tests with coverage..."
|
||||
cd proxy && go test -v -coverprofile=coverage.out ./...
|
||||
cd proxy && go tool cover -html=coverage.out -o coverage.html
|
||||
@echo "📊 Coverage report generated: proxy/coverage.html"
|
||||
|
||||
# Database operations
|
||||
db-reset:
|
||||
@echo "🗑️ Resetting database..."
|
||||
|
|
|
|||
37
config.yaml
Normal file
37
config.yaml
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
# LLM Proxy Configuration
|
||||
# This file configures the LLM proxy server and its integrations
|
||||
|
||||
# Server configuration
|
||||
server:
|
||||
port: 3001
|
||||
timeouts:
|
||||
read: 10m # Read timeout (default: 10 minutes)
|
||||
write: 10m # Write timeout (default: 10 minutes)
|
||||
idle: 10m # Idle timeout (default: 10 minutes)
|
||||
|
||||
# Provider configurations
|
||||
providers:
|
||||
# Anthropic Claude configuration
|
||||
anthropic:
|
||||
base_url: "https://api.anthropic.com"
|
||||
version: "2023-06-01"
|
||||
max_retries: 3
|
||||
|
||||
# OpenAI configuration
|
||||
openai:
|
||||
# API key can be set here or via OPENAI_API_KEY environment variable
|
||||
# api_key: "your-api-key-here"
|
||||
base_url: "https://proxy-shopify-ai.local.shop.dev"
|
||||
|
||||
# Storage configuration
|
||||
storage:
|
||||
# SQLite database path for storing request history
|
||||
db_path: "requests.db"
|
||||
|
||||
# Subagent mappings
|
||||
# Maps subagent types to specific models
|
||||
subagents:
|
||||
mappings:
|
||||
streaming-systems-engineer: "gpt-4o"
|
||||
# Add more subagent mappings as needed
|
||||
# example-agent: "gpt-4o"
|
||||
87
config.yaml.example
Normal file
87
config.yaml.example
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
# LLM Proxy Configuration Example
|
||||
# This file demonstrates all available configuration options
|
||||
# Copy this file to config.yaml and customize as needed
|
||||
|
||||
# Server configuration
|
||||
server:
|
||||
# Port to listen on (default: 3001)
|
||||
port: 3001
|
||||
|
||||
# Timeout configurations
|
||||
timeouts:
|
||||
# Maximum duration for reading the entire request, including the body
|
||||
read: 10m
|
||||
|
||||
# Maximum duration before timing out writes of the response
|
||||
write: 10m
|
||||
|
||||
# Maximum amount of time to wait for the next request when keep-alives are enabled
|
||||
idle: 10m
|
||||
|
||||
# Provider configurations
|
||||
providers:
|
||||
# Anthropic Claude configuration
|
||||
anthropic:
|
||||
# Base URL for Anthropic API (can be changed for custom endpoints)
|
||||
base_url: "https://api.anthropic.com"
|
||||
|
||||
# API version to use
|
||||
version: "2023-06-01"
|
||||
|
||||
# Maximum number of retries for failed requests
|
||||
max_retries: 3
|
||||
|
||||
# OpenAI configuration
|
||||
openai:
|
||||
# API key for OpenAI
|
||||
# Can also be set via OPENAI_API_KEY environment variable
|
||||
# api_key: "sk-..."
|
||||
|
||||
# Base URL for OpenAI API (can be changed for custom endpoints)
|
||||
# Can also be set via OPENAI_BASE_URL environment variable
|
||||
# base_url: "https://api.openai.com"
|
||||
|
||||
# Storage configuration
|
||||
storage:
|
||||
# SQLite database path for storing request history
|
||||
db_path: "requests.db"
|
||||
|
||||
# Directory for storing request files (if needed in future)
|
||||
# requests_dir: "./requests"
|
||||
|
||||
# Subagent mappings
|
||||
# Maps subagent types to specific models
|
||||
subagents:
|
||||
mappings:
|
||||
# Code review specialist (example)
|
||||
# code-reviewer: "gpt-4o"
|
||||
|
||||
# Data analysis expert (example)
|
||||
# data-analyst: "claude-3-5-sonnet-20241022"
|
||||
|
||||
# Documentation writer (example)
|
||||
# doc-writer: "gpt-3.5-turbo"
|
||||
|
||||
# Environment variable overrides:
|
||||
# The following environment variables will override the YAML configuration:
|
||||
#
|
||||
# Server:
|
||||
# PORT - Server port
|
||||
# READ_TIMEOUT - Read timeout duration
|
||||
# WRITE_TIMEOUT - Write timeout duration
|
||||
# IDLE_TIMEOUT - Idle timeout duration
|
||||
#
|
||||
# Anthropic:
|
||||
# ANTHROPIC_FORWARD_URL - Anthropic base URL
|
||||
# ANTHROPIC_VERSION - Anthropic API version
|
||||
# ANTHROPIC_MAX_RETRIES - Maximum retries for Anthropic requests
|
||||
#
|
||||
# OpenAI:
|
||||
# OPENAI_API_KEY - OpenAI API key
|
||||
#
|
||||
# Storage:
|
||||
# DB_PATH - Database file path
|
||||
#
|
||||
# Subagents:
|
||||
# SUBAGENT_MAPPINGS - Comma-separated subagent:model pairs
|
||||
# Example: "code-reviewer:claude-3-5-sonnet"
|
||||
|
|
@ -15,6 +15,7 @@ import (
|
|||
"github.com/seifghazi/claude-code-monitor/internal/config"
|
||||
"github.com/seifghazi/claude-code-monitor/internal/handler"
|
||||
"github.com/seifghazi/claude-code-monitor/internal/middleware"
|
||||
"github.com/seifghazi/claude-code-monitor/internal/provider"
|
||||
"github.com/seifghazi/claude-code-monitor/internal/service"
|
||||
)
|
||||
|
||||
|
|
@ -26,6 +27,15 @@ func main() {
|
|||
logger.Fatalf("❌ Failed to load configuration: %v", err)
|
||||
}
|
||||
|
||||
// Initialize providers
|
||||
providers := make(map[string]provider.Provider)
|
||||
providers["anthropic"] = provider.NewAnthropicProvider(&cfg.Providers.Anthropic)
|
||||
providers["openai"] = provider.NewOpenAIProvider(&cfg.Providers.OpenAI)
|
||||
|
||||
// Initialize model router
|
||||
modelRouter := service.NewModelRouter(cfg, providers, logger)
|
||||
|
||||
// Use legacy anthropic service for backward compatibility
|
||||
anthropicService := service.NewAnthropicService(&cfg.Anthropic)
|
||||
|
||||
// Use SQLite storage
|
||||
|
|
@ -35,7 +45,7 @@ func main() {
|
|||
}
|
||||
logger.Println("🗿 SQLite database ready")
|
||||
|
||||
h := handler.New(anthropicService, storageService, logger)
|
||||
h := handler.New(anthropicService, storageService, logger, modelRouter)
|
||||
|
||||
r := mux.NewRouter()
|
||||
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ require (
|
|||
github.com/gorilla/mux v1.8.1
|
||||
github.com/joho/godotenv v1.5.1
|
||||
github.com/mattn/go-sqlite3 v1.14.28
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
require github.com/felixge/httpsnoop v1.0.3 // indirect
|
||||
|
|
|
|||
|
|
@ -8,3 +8,7 @@ github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
|||
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||
github.com/mattn/go-sqlite3 v1.14.28 h1:ThEiQrnbtumT+QMknw63Befp/ce/nUPgBPMlRFEum7A=
|
||||
github.com/mattn/go-sqlite3 v1.14.28/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
|
|
|||
|
|
@ -1,27 +1,56 @@
|
|||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/joho/godotenv"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Server ServerConfig
|
||||
Server ServerConfig `yaml:"server"`
|
||||
Providers ProvidersConfig `yaml:"providers"`
|
||||
Storage StorageConfig `yaml:"storage"`
|
||||
Subagents SubagentsConfig `yaml:"subagents"`
|
||||
// Legacy fields for backward compatibility
|
||||
Anthropic AnthropicConfig
|
||||
Storage StorageConfig
|
||||
}
|
||||
|
||||
type ServerConfig struct {
|
||||
Port string
|
||||
Port string `yaml:"port"`
|
||||
Timeouts TimeoutsConfig `yaml:"timeouts"`
|
||||
// Legacy fields
|
||||
ReadTimeout time.Duration
|
||||
WriteTimeout time.Duration
|
||||
IdleTimeout time.Duration
|
||||
}
|
||||
|
||||
type TimeoutsConfig struct {
|
||||
Read string `yaml:"read"`
|
||||
Write string `yaml:"write"`
|
||||
Idle string `yaml:"idle"`
|
||||
}
|
||||
|
||||
type ProvidersConfig struct {
|
||||
Anthropic AnthropicProviderConfig `yaml:"anthropic"`
|
||||
OpenAI OpenAIProviderConfig `yaml:"openai"`
|
||||
}
|
||||
|
||||
type AnthropicProviderConfig struct {
|
||||
BaseURL string `yaml:"base_url"`
|
||||
Version string `yaml:"version"`
|
||||
MaxRetries int `yaml:"max_retries"`
|
||||
}
|
||||
|
||||
type OpenAIProviderConfig struct {
|
||||
BaseURL string `yaml:"base_url"`
|
||||
APIKey string `yaml:"api_key"`
|
||||
}
|
||||
|
||||
type AnthropicConfig struct {
|
||||
BaseURL string
|
||||
Version string
|
||||
|
|
@ -29,8 +58,12 @@ type AnthropicConfig struct {
|
|||
}
|
||||
|
||||
type StorageConfig struct {
|
||||
RequestsDir string
|
||||
DBPath string
|
||||
RequestsDir string `yaml:"requests_dir"`
|
||||
DBPath string `yaml:"db_path"`
|
||||
}
|
||||
|
||||
type SubagentsConfig struct {
|
||||
Mappings map[string]string `yaml:"mappings"`
|
||||
}
|
||||
|
||||
func Load() (*Config, error) {
|
||||
|
|
@ -45,26 +78,84 @@ func Load() (*Config, error) {
|
|||
}
|
||||
}
|
||||
|
||||
// Start with default configuration
|
||||
cfg := &Config{
|
||||
Server: ServerConfig{
|
||||
Port: getEnv("PORT", "3001"),
|
||||
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
|
||||
ReadTimeout: getDuration("READ_TIMEOUT", 600*time.Second),
|
||||
WriteTimeout: getDuration("WRITE_TIMEOUT", 600*time.Second),
|
||||
IdleTimeout: getDuration("IDLE_TIMEOUT", 600*time.Second),
|
||||
},
|
||||
Providers: ProvidersConfig{
|
||||
Anthropic: AnthropicProviderConfig{
|
||||
BaseURL: getEnv("ANTHROPIC_FORWARD_URL", "https://api.anthropic.com"),
|
||||
Version: getEnv("ANTHROPIC_VERSION", "2023-06-01"),
|
||||
MaxRetries: getInt("ANTHROPIC_MAX_RETRIES", 3),
|
||||
},
|
||||
OpenAI: OpenAIProviderConfig{
|
||||
BaseURL: getEnv("OPENAI_BASE_URL", "https://api.openai.com"),
|
||||
APIKey: getEnv("OPENAI_API_KEY", ""),
|
||||
},
|
||||
},
|
||||
Storage: StorageConfig{
|
||||
DBPath: getEnv("DB_PATH", "requests.db"),
|
||||
},
|
||||
Subagents: SubagentsConfig{
|
||||
Mappings: make(map[string]string),
|
||||
},
|
||||
// Legacy field for backward compatibility
|
||||
Anthropic: AnthropicConfig{
|
||||
BaseURL: getEnv("ANTHROPIC_FORWARD_URL", "https://api.anthropic.com"),
|
||||
Version: getEnv("ANTHROPIC_VERSION", "2023-06-01"),
|
||||
MaxRetries: getInt("ANTHROPIC_MAX_RETRIES", 3),
|
||||
},
|
||||
Storage: StorageConfig{
|
||||
DBPath: getEnv("DB_PATH", "requests.db"),
|
||||
},
|
||||
}
|
||||
|
||||
// Try to load from YAML config file if specified
|
||||
configPath := getEnv("CONFIG_PATH", "../config.yaml")
|
||||
if configPath != "" {
|
||||
if err := cfg.loadFromFile(configPath); err != nil {
|
||||
// Log error but continue with defaults
|
||||
fmt.Printf("Warning: Failed to load config from %s: %v\n", configPath, err)
|
||||
}
|
||||
}
|
||||
|
||||
// After loading from file, apply any timeout conversions if needed
|
||||
if cfg.Server.Timeouts.Read != "" {
|
||||
if duration, err := time.ParseDuration(cfg.Server.Timeouts.Read); err == nil {
|
||||
cfg.Server.ReadTimeout = duration
|
||||
}
|
||||
}
|
||||
if cfg.Server.Timeouts.Write != "" {
|
||||
if duration, err := time.ParseDuration(cfg.Server.Timeouts.Write); err == nil {
|
||||
cfg.Server.WriteTimeout = duration
|
||||
}
|
||||
}
|
||||
if cfg.Server.Timeouts.Idle != "" {
|
||||
if duration, err := time.ParseDuration(cfg.Server.Timeouts.Idle); err == nil {
|
||||
cfg.Server.IdleTimeout = duration
|
||||
}
|
||||
}
|
||||
|
||||
// Sync legacy Anthropic config with new structure
|
||||
cfg.Anthropic = AnthropicConfig{
|
||||
BaseURL: cfg.Providers.Anthropic.BaseURL,
|
||||
Version: cfg.Providers.Anthropic.Version,
|
||||
MaxRetries: cfg.Providers.Anthropic.MaxRetries,
|
||||
}
|
||||
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func (c *Config) loadFromFile(path string) error {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return yaml.Unmarshal(data, c)
|
||||
}
|
||||
|
||||
func getEnv(key, defaultValue string) string {
|
||||
if value := os.Getenv(key); value != "" {
|
||||
return value
|
||||
|
|
|
|||
202
proxy/internal/config/config_test.go
Normal file
202
proxy/internal/config/config_test.go
Normal file
|
|
@ -0,0 +1,202 @@
|
|||
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
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -25,15 +25,19 @@ type Handler struct {
|
|||
anthropicService service.AnthropicService
|
||||
storageService service.StorageService
|
||||
conversationService service.ConversationService
|
||||
modelRouter *service.ModelRouter
|
||||
logger *log.Logger
|
||||
}
|
||||
|
||||
func New(anthropicService service.AnthropicService, storageService service.StorageService, logger *log.Logger) *Handler {
|
||||
func New(anthropicService service.AnthropicService, storageService service.StorageService, logger *log.Logger, modelRouter *service.ModelRouter) *Handler {
|
||||
conversationService := service.NewConversationService()
|
||||
|
||||
return &Handler{
|
||||
anthropicService: anthropicService,
|
||||
storageService: storageService,
|
||||
conversationService: conversationService,
|
||||
modelRouter: modelRouter,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -81,10 +85,22 @@ func (h *Handler) Messages(w http.ResponseWriter, r *http.Request) {
|
|||
log.Printf("❌ Error saving request: %v", err)
|
||||
}
|
||||
|
||||
// Forward the request to Anthropic
|
||||
resp, err := h.anthropicService.ForwardRequest(r.Context(), r)
|
||||
// Use model router to determine provider and route the request
|
||||
provider, originalModel, err := h.modelRouter.RouteRequest(&req)
|
||||
if err != nil {
|
||||
log.Printf("❌ Error forwarding to Anthropic API: %v", err)
|
||||
log.Printf("❌ Error routing request: %v", err)
|
||||
writeErrorResponse(w, "Failed to route request", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Update request log with original model (for tracking)
|
||||
requestLog.OriginalModel = originalModel
|
||||
requestLog.RoutedModel = req.Model
|
||||
|
||||
// Forward the request to the selected provider
|
||||
resp, err := provider.ForwardRequest(r.Context(), r)
|
||||
if err != nil {
|
||||
log.Printf("❌ Error forwarding to %s API: %v", provider.Name(), err)
|
||||
writeErrorResponse(w, "Failed to forward request", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
|
|
|||
287
proxy/internal/handler/handlers_test.go
Normal file
287
proxy/internal/handler/handlers_test.go
Normal file
|
|
@ -0,0 +1,287 @@
|
|||
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"])
|
||||
}
|
||||
}
|
||||
|
|
@ -32,6 +32,8 @@ type RequestLog struct {
|
|||
Headers map[string][]string `json:"headers"`
|
||||
Body interface{} `json:"body"`
|
||||
Model string `json:"model,omitempty"`
|
||||
OriginalModel string `json:"originalModel,omitempty"`
|
||||
RoutedModel string `json:"routedModel,omitempty"`
|
||||
UserAgent string `json:"userAgent"`
|
||||
ContentType string `json:"contentType"`
|
||||
PromptGrade *PromptGrade `json:"promptGrade,omitempty"`
|
||||
|
|
|
|||
131
proxy/internal/provider/anthropic.go
Normal file
131
proxy/internal/provider/anthropic.go
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
package provider
|
||||
|
||||
import (
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/seifghazi/claude-code-monitor/internal/config"
|
||||
)
|
||||
|
||||
type AnthropicProvider struct {
|
||||
client *http.Client
|
||||
config *config.AnthropicProviderConfig
|
||||
}
|
||||
|
||||
func NewAnthropicProvider(cfg *config.AnthropicProviderConfig) Provider {
|
||||
return &AnthropicProvider{
|
||||
client: &http.Client{
|
||||
Timeout: 300 * time.Second, // 5 minutes timeout
|
||||
},
|
||||
config: cfg,
|
||||
}
|
||||
}
|
||||
|
||||
func (p *AnthropicProvider) Name() string {
|
||||
return "anthropic"
|
||||
}
|
||||
|
||||
func (p *AnthropicProvider) 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(p.config.BaseURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse base URL '%s': %w", p.config.BaseURL, err)
|
||||
}
|
||||
|
||||
if baseURL.Scheme == "" || baseURL.Host == "" {
|
||||
return nil, fmt.Errorf("invalid base URL, scheme and host are required: %s", p.config.BaseURL)
|
||||
}
|
||||
|
||||
// Update the destination URL
|
||||
proxyReq.URL.Scheme = baseURL.Scheme
|
||||
proxyReq.URL.Host = baseURL.Host
|
||||
proxyReq.URL.Path = path.Join(baseURL.Path, originalReq.URL.Path)
|
||||
|
||||
// Preserve query parameters
|
||||
proxyReq.URL.RawQuery = originalReq.URL.RawQuery
|
||||
|
||||
// Update request headers
|
||||
proxyReq.RequestURI = ""
|
||||
proxyReq.Host = baseURL.Host
|
||||
|
||||
// Remove hop-by-hop headers
|
||||
removeHopByHopHeaders(proxyReq.Header)
|
||||
|
||||
// Add required headers if not present
|
||||
if proxyReq.Header.Get("anthropic-version") == "" {
|
||||
proxyReq.Header.Set("anthropic-version", p.config.Version)
|
||||
}
|
||||
|
||||
// Support gzip encoding
|
||||
proxyReq.Header.Set("Accept-Encoding", "gzip")
|
||||
|
||||
// Forward the request
|
||||
resp, err := p.client.Do(proxyReq)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to forward request: %w", err)
|
||||
}
|
||||
|
||||
// Handle gzip-encoded responses
|
||||
if resp.Header.Get("Content-Encoding") == "gzip" {
|
||||
resp.Header.Del("Content-Encoding")
|
||||
resp.Header.Del("Content-Length")
|
||||
gzipReader, err := gzip.NewReader(resp.Body)
|
||||
if err != nil {
|
||||
resp.Body.Close()
|
||||
return nil, fmt.Errorf("failed to create gzip reader: %w", err)
|
||||
}
|
||||
resp.Body = &gzipResponseBody{
|
||||
Reader: gzipReader,
|
||||
closer: resp.Body,
|
||||
}
|
||||
}
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
type gzipResponseBody struct {
|
||||
io.Reader
|
||||
closer io.Closer
|
||||
}
|
||||
|
||||
func (g *gzipResponseBody) Close() error {
|
||||
if gzReader, ok := g.Reader.(*gzip.Reader); ok {
|
||||
gzReader.Close()
|
||||
}
|
||||
return g.closer.Close()
|
||||
}
|
||||
|
||||
func removeHopByHopHeaders(header http.Header) {
|
||||
hopByHopHeaders := []string{
|
||||
"Connection",
|
||||
"Keep-Alive",
|
||||
"Proxy-Authenticate",
|
||||
"Proxy-Authorization",
|
||||
"TE",
|
||||
"Trailers",
|
||||
"Transfer-Encoding",
|
||||
"Upgrade",
|
||||
}
|
||||
|
||||
for _, h := range hopByHopHeaders {
|
||||
header.Del(h)
|
||||
}
|
||||
|
||||
// Remove any headers specified in the Connection header
|
||||
if connection := header.Get("Connection"); connection != "" {
|
||||
for _, h := range strings.Split(connection, ",") {
|
||||
header.Del(strings.TrimSpace(h))
|
||||
}
|
||||
header.Del("Connection")
|
||||
}
|
||||
}
|
||||
200
proxy/internal/provider/openai.go
Normal file
200
proxy/internal/provider/openai.go
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
package provider
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"github.com/seifghazi/claude-code-monitor/internal/config"
|
||||
"github.com/seifghazi/claude-code-monitor/internal/model"
|
||||
)
|
||||
|
||||
type OpenAIProvider struct {
|
||||
client *http.Client
|
||||
config *config.OpenAIProviderConfig
|
||||
}
|
||||
|
||||
func NewOpenAIProvider(cfg *config.OpenAIProviderConfig) Provider {
|
||||
return &OpenAIProvider{
|
||||
client: &http.Client{
|
||||
Timeout: 300 * time.Second, // 5 minutes timeout
|
||||
},
|
||||
config: cfg,
|
||||
}
|
||||
}
|
||||
|
||||
func (p *OpenAIProvider) Name() string {
|
||||
return "openai"
|
||||
}
|
||||
|
||||
func (p *OpenAIProvider) ForwardRequest(ctx context.Context, originalReq *http.Request) (*http.Response, error) {
|
||||
// First, we need to convert the Anthropic request to OpenAI format
|
||||
bodyBytes, err := io.ReadAll(originalReq.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read request body: %w", err)
|
||||
}
|
||||
originalReq.Body = io.NopCloser(bytes.NewReader(bodyBytes))
|
||||
|
||||
var anthropicReq model.AnthropicRequest
|
||||
if err := json.Unmarshal(bodyBytes, &anthropicReq); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse anthropic request: %w", err)
|
||||
}
|
||||
|
||||
// Convert to OpenAI format
|
||||
openAIReq := convertAnthropicToOpenAI(&anthropicReq)
|
||||
newBodyBytes, err := json.Marshal(openAIReq)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal openai request: %w", err)
|
||||
}
|
||||
|
||||
// Clone the request with new body
|
||||
proxyReq := originalReq.Clone(ctx)
|
||||
proxyReq.Body = io.NopCloser(bytes.NewReader(newBodyBytes))
|
||||
proxyReq.ContentLength = int64(len(newBodyBytes))
|
||||
|
||||
// Parse the configured base URL
|
||||
baseURL, err := url.Parse(p.config.BaseURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse base URL '%s': %w", p.config.BaseURL, err)
|
||||
}
|
||||
|
||||
// Update the destination URL for OpenAI
|
||||
proxyReq.URL.Scheme = baseURL.Scheme
|
||||
proxyReq.URL.Host = baseURL.Host
|
||||
proxyReq.URL.Path = "/v1/chat/completions" // OpenAI endpoint
|
||||
|
||||
// Update request headers
|
||||
proxyReq.RequestURI = ""
|
||||
proxyReq.Host = baseURL.Host
|
||||
|
||||
// Remove Anthropic-specific headers
|
||||
proxyReq.Header.Del("anthropic-version")
|
||||
proxyReq.Header.Del("x-api-key")
|
||||
|
||||
// Add OpenAI headers
|
||||
if p.config.APIKey != "" {
|
||||
proxyReq.Header.Set("Authorization", "Bearer "+p.config.APIKey)
|
||||
}
|
||||
proxyReq.Header.Set("Content-Type", "application/json")
|
||||
|
||||
// Forward the request
|
||||
resp, err := p.client.Do(proxyReq)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to forward request: %w", err)
|
||||
}
|
||||
|
||||
// For streaming responses, we need to convert back to Anthropic format
|
||||
if anthropicReq.Stream {
|
||||
// Create a pipe to transform the response
|
||||
pr, pw := io.Pipe()
|
||||
|
||||
// Start a goroutine to transform the stream
|
||||
go func() {
|
||||
defer pw.Close()
|
||||
transformOpenAIStreamToAnthropic(resp.Body, pw)
|
||||
}()
|
||||
|
||||
// Replace the response body with our transformed stream
|
||||
resp.Body = pr
|
||||
} else {
|
||||
// For non-streaming, read and convert the response
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read response body: %w", err)
|
||||
}
|
||||
|
||||
// Convert OpenAI response back to Anthropic format
|
||||
transformedBody := transformOpenAIResponseToAnthropic(respBody)
|
||||
resp.Body = io.NopCloser(bytes.NewReader(transformedBody))
|
||||
resp.ContentLength = int64(len(transformedBody))
|
||||
resp.Header.Set("Content-Length", fmt.Sprintf("%d", len(transformedBody)))
|
||||
}
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func convertAnthropicToOpenAI(req *model.AnthropicRequest) map[string]interface{} {
|
||||
messages := []map[string]interface{}{}
|
||||
|
||||
// Add system messages
|
||||
for _, sysMsg := range req.System {
|
||||
messages = append(messages, map[string]interface{}{
|
||||
"role": "system",
|
||||
"content": sysMsg.Text,
|
||||
})
|
||||
}
|
||||
|
||||
// Add conversation messages
|
||||
for _, msg := range req.Messages {
|
||||
// Get content blocks from the message
|
||||
contentBlocks := msg.GetContentBlocks()
|
||||
content := ""
|
||||
if len(contentBlocks) > 0 {
|
||||
// Use the first text block
|
||||
content = contentBlocks[0].Text
|
||||
}
|
||||
|
||||
messages = append(messages, map[string]interface{}{
|
||||
"role": msg.Role,
|
||||
"content": content,
|
||||
})
|
||||
}
|
||||
|
||||
openAIReq := map[string]interface{}{
|
||||
"model": req.Model,
|
||||
"messages": messages,
|
||||
"temperature": req.Temperature,
|
||||
"max_tokens": req.MaxTokens,
|
||||
"stream": req.Stream,
|
||||
}
|
||||
|
||||
return openAIReq
|
||||
}
|
||||
|
||||
func transformOpenAIResponseToAnthropic(respBody []byte) []byte {
|
||||
// This is a simplified transformation
|
||||
// In production, you'd want to handle all fields properly
|
||||
var openAIResp map[string]interface{}
|
||||
if err := json.Unmarshal(respBody, &openAIResp); err != nil {
|
||||
return respBody // Return as-is if we can't parse
|
||||
}
|
||||
|
||||
// Extract the assistant's message
|
||||
content := ""
|
||||
if choices, ok := openAIResp["choices"].([]interface{}); ok && len(choices) > 0 {
|
||||
if choice, ok := choices[0].(map[string]interface{}); ok {
|
||||
if msg, ok := choice["message"].(map[string]interface{}); ok {
|
||||
if c, ok := msg["content"].(string); ok {
|
||||
content = c
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build Anthropic-style response
|
||||
anthropicResp := map[string]interface{}{
|
||||
"id": openAIResp["id"],
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"content": []map[string]string{{"type": "text", "text": content}},
|
||||
"model": openAIResp["model"],
|
||||
"usage": openAIResp["usage"],
|
||||
}
|
||||
|
||||
result, _ := json.Marshal(anthropicResp)
|
||||
return result
|
||||
}
|
||||
|
||||
func transformOpenAIStreamToAnthropic(openAIStream io.ReadCloser, anthropicStream io.Writer) {
|
||||
defer openAIStream.Close()
|
||||
|
||||
// This is a placeholder - in production you'd parse SSE events
|
||||
// and transform them from OpenAI format to Anthropic format
|
||||
io.Copy(anthropicStream, openAIStream)
|
||||
}
|
||||
15
proxy/internal/provider/provider.go
Normal file
15
proxy/internal/provider/provider.go
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
package provider
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// Provider is the interface that all LLM providers must implement
|
||||
type Provider interface {
|
||||
// Name returns the provider name (e.g., "anthropic", "openai")
|
||||
Name() string
|
||||
|
||||
// ForwardRequest forwards a request to the provider's API
|
||||
ForwardRequest(ctx context.Context, originalReq *http.Request) (*http.Response, error)
|
||||
}
|
||||
165
proxy/internal/service/model_router.go
Normal file
165
proxy/internal/service/model_router.go
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
package service
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/seifghazi/claude-code-monitor/internal/config"
|
||||
"github.com/seifghazi/claude-code-monitor/internal/model"
|
||||
"github.com/seifghazi/claude-code-monitor/internal/provider"
|
||||
)
|
||||
|
||||
type ModelRouter struct {
|
||||
config *config.Config
|
||||
providers map[string]provider.Provider
|
||||
subagentMappings map[string]string // agentName -> targetModel
|
||||
customAgentPrompts map[string]SubagentDefinition // promptHash -> definition
|
||||
logger *log.Logger
|
||||
}
|
||||
|
||||
type SubagentDefinition struct {
|
||||
Name string
|
||||
TargetModel string
|
||||
TargetProvider string
|
||||
FullPrompt string // Store for debugging
|
||||
}
|
||||
|
||||
func NewModelRouter(cfg *config.Config, providers map[string]provider.Provider, logger *log.Logger) *ModelRouter {
|
||||
router := &ModelRouter{
|
||||
config: cfg,
|
||||
providers: providers,
|
||||
subagentMappings: cfg.Subagents.Mappings,
|
||||
customAgentPrompts: make(map[string]SubagentDefinition),
|
||||
logger: logger,
|
||||
}
|
||||
|
||||
router.loadCustomAgents()
|
||||
return router
|
||||
}
|
||||
|
||||
// extractStaticPrompt extracts the portion before "Notes:" if it exists
|
||||
func (r *ModelRouter) extractStaticPrompt(systemPrompt string) string {
|
||||
// Find the "Notes:" section
|
||||
notesIndex := strings.Index(systemPrompt, "\nNotes:")
|
||||
if notesIndex == -1 {
|
||||
notesIndex = strings.Index(systemPrompt, "\n\nNotes:")
|
||||
}
|
||||
|
||||
if notesIndex != -1 {
|
||||
// Return only the part before "Notes:"
|
||||
return strings.TrimSpace(systemPrompt[:notesIndex])
|
||||
}
|
||||
|
||||
// If no "Notes:" section, return the whole prompt
|
||||
return strings.TrimSpace(systemPrompt)
|
||||
}
|
||||
|
||||
func (r *ModelRouter) loadCustomAgents() {
|
||||
for agentName, targetModel := range r.subagentMappings {
|
||||
// Try loading from project level first, then user level
|
||||
paths := []string{
|
||||
fmt.Sprintf(".claude/agents/%s.md", agentName),
|
||||
fmt.Sprintf("%s/.claude/agents/%s.md", os.Getenv("HOME"), agentName),
|
||||
}
|
||||
|
||||
for _, path := range paths {
|
||||
content, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Parse agent file: metadata\n---\nsystem prompt
|
||||
parts := strings.Split(string(content), "\n---\n")
|
||||
if len(parts) >= 2 {
|
||||
systemPrompt := strings.TrimSpace(parts[1])
|
||||
|
||||
// Extract only the static part (before "Notes:" if it exists)
|
||||
staticPrompt := r.extractStaticPrompt(systemPrompt)
|
||||
hash := r.hashString(staticPrompt)
|
||||
|
||||
// Determine provider for the target model
|
||||
providerName := r.getProviderNameForModel(targetModel)
|
||||
|
||||
r.customAgentPrompts[hash] = SubagentDefinition{
|
||||
Name: agentName,
|
||||
TargetModel: targetModel,
|
||||
TargetProvider: providerName,
|
||||
FullPrompt: staticPrompt,
|
||||
}
|
||||
|
||||
r.logger.Printf("Loaded custom agent: %s (hash: %s) -> %s",
|
||||
agentName, hash, targetModel)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
// Claude Code pattern: Check if we have exactly 2 system messages
|
||||
if len(req.System) == 2 {
|
||||
// First should be "You are Claude Code..."
|
||||
if strings.Contains(req.System[0].Text, "You are Claude Code") {
|
||||
// Second message could be either:
|
||||
// 1. A regular Claude Code prompt (no Notes: section)
|
||||
// 2. A subagent prompt (may have Notes: section)
|
||||
|
||||
fullPrompt := req.System[1].Text
|
||||
|
||||
// Extract static portion (before "Notes:" if it exists)
|
||||
staticPrompt := r.extractStaticPrompt(fullPrompt)
|
||||
promptHash := r.hashString(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)
|
||||
|
||||
req.Model = definition.TargetModel
|
||||
provider := r.providers[definition.TargetProvider]
|
||||
if provider == nil {
|
||||
return nil, originalModel, fmt.Errorf("provider %s not found for model %s",
|
||||
definition.TargetProvider, definition.TargetModel)
|
||||
}
|
||||
|
||||
return provider, originalModel, nil
|
||||
}
|
||||
|
||||
// This is a regular Claude Code request (not a known subagent)
|
||||
r.logger.Printf("Regular Claude Code request detected, using original model %s", 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)
|
||||
}
|
||||
|
||||
return provider, originalModel, nil
|
||||
}
|
||||
|
||||
func (r *ModelRouter) hashString(s string) string {
|
||||
h := sha256.New()
|
||||
h.Write([]byte(s))
|
||||
return hex.EncodeToString(h.Sum(nil))[:16]
|
||||
}
|
||||
|
||||
func (r *ModelRouter) getProviderNameForModel(model string) string {
|
||||
// Map models to providers
|
||||
if strings.HasPrefix(model, "claude") {
|
||||
return "anthropic"
|
||||
} else if strings.HasPrefix(model, "gpt") {
|
||||
return "openai"
|
||||
}
|
||||
// Default to anthropic
|
||||
return "anthropic"
|
||||
}
|
||||
141
proxy/internal/service/model_router_test.go
Normal file
141
proxy/internal/service/model_router_test.go
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
package service
|
||||
|
||||
import (
|
||||
"log"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/seifghazi/claude-code-monitor/internal/config"
|
||||
"github.com/seifghazi/claude-code-monitor/internal/model"
|
||||
"github.com/seifghazi/claude-code-monitor/internal/provider"
|
||||
)
|
||||
|
||||
func TestModelRouter_EdgeCases(t *testing.T) {
|
||||
// Setup
|
||||
cfg := &config.Config{
|
||||
Subagents: config.SubagentsConfig{
|
||||
Mappings: map[string]string{
|
||||
"streaming-systems-engineer": "gpt-4o",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
providers := make(map[string]provider.Provider)
|
||||
// Mock providers - in real test you'd use mocks
|
||||
providers["anthropic"] = nil
|
||||
providers["openai"] = nil
|
||||
|
||||
logger := log.New(os.Stdout, "test: ", log.LstdFlags)
|
||||
router := NewModelRouter(cfg, providers, logger)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
request *model.AnthropicRequest
|
||||
expectedRoute string
|
||||
expectedModel string
|
||||
description string
|
||||
}{
|
||||
{
|
||||
name: "Regular Claude Code request (no Notes section)",
|
||||
request: &model.AnthropicRequest{
|
||||
Model: "claude-3-opus-20240229",
|
||||
System: []model.AnthropicSystemMessage{
|
||||
{Text: "You are Claude Code, Anthropic's official CLI for Claude."},
|
||||
{Text: "You are an interactive CLI tool that helps users with software engineering tasks. Use the instructions below and the tools available to you to assist the user."},
|
||||
},
|
||||
},
|
||||
expectedRoute: "anthropic",
|
||||
expectedModel: "claude-3-opus-20240229",
|
||||
description: "Regular Claude Code requests should use original model",
|
||||
},
|
||||
{
|
||||
name: "Non-Claude Code request",
|
||||
request: &model.AnthropicRequest{
|
||||
Model: "claude-3-opus-20240229",
|
||||
System: []model.AnthropicSystemMessage{
|
||||
{Text: "You are a helpful assistant."},
|
||||
},
|
||||
},
|
||||
expectedRoute: "anthropic",
|
||||
expectedModel: "claude-3-opus-20240229",
|
||||
description: "Non-Claude Code requests should use original model",
|
||||
},
|
||||
{
|
||||
name: "Single system message",
|
||||
request: &model.AnthropicRequest{
|
||||
Model: "claude-3-opus-20240229",
|
||||
System: []model.AnthropicSystemMessage{},
|
||||
},
|
||||
expectedRoute: "anthropic",
|
||||
expectedModel: "claude-3-opus-20240229",
|
||||
description: "Requests with no system messages should use original model",
|
||||
},
|
||||
}
|
||||
|
||||
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
|
||||
staticPrompt := router.extractStaticPrompt(fullPrompt)
|
||||
|
||||
// Verify no "Notes:" in static prompt
|
||||
if contains(staticPrompt, "Notes:") {
|
||||
t.Errorf("Static prompt should not contain 'Notes:' section")
|
||||
}
|
||||
}
|
||||
|
||||
// Log for manual verification
|
||||
t.Logf("Test case: %s", tt.description)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestModelRouter_ExtractStaticPrompt(t *testing.T) {
|
||||
router := &ModelRouter{}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "Prompt with Notes section",
|
||||
input: "You are an expert engineer.\n\nNotes:\n- Some dynamic content\n- More notes",
|
||||
expected: "You are an expert engineer.",
|
||||
},
|
||||
{
|
||||
name: "Prompt without Notes section",
|
||||
input: "You are an expert engineer.\nNo notes here.",
|
||||
expected: "You are an expert engineer.\nNo notes here.",
|
||||
},
|
||||
{
|
||||
name: "Prompt with double newline before Notes",
|
||||
input: "You are an expert.\n\nNotes:\nDynamic content",
|
||||
expected: "You are an expert.",
|
||||
},
|
||||
{
|
||||
name: "Empty prompt",
|
||||
input: "",
|
||||
expected: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := router.extractStaticPrompt(tt.input)
|
||||
if result != tt.expected {
|
||||
t.Errorf("extractStaticPrompt() = %q, want %q", result, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func contains(s, substr string) bool {
|
||||
return len(s) >= len(substr) && (s == substr || len(substr) == 0 ||
|
||||
(len(s) > 0 && len(substr) > 0 && s[0:len(substr)] == substr) ||
|
||||
(len(s) > len(substr) && contains(s[1:], substr)))
|
||||
}
|
||||
136
proxy/test_e2e.sh
Normal file
136
proxy/test_e2e.sh
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
#!/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