diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md deleted file mode 100644 index 81c9aa7..0000000 --- a/.github/pull_request_template.md +++ /dev/null @@ -1,38 +0,0 @@ -## Description - -Brief description of the changes in this PR. - -## Type of Change - -- [ ] 🐛 Bug fix (non-breaking change which fixes an issue) -- [ ] ✨ New feature (non-breaking change which adds functionality) -- [ ] 💥 Breaking change (fix or feature that would cause existing functionality to not work as expected) -- [ ] 📚 Documentation update -- [ ] 🔧 Configuration change -- [ ] ♻️ Code refactoring (no functional changes) - -## Testing - -- [ ] I have run `make check` locally and all checks pass -- [ ] I have tested the changes manually -- [ ] I have added tests that prove my fix is effective or that my feature works - -## Checklist - -- [ ] My code follows the project's style guidelines (enforced by `make fmt` and `make lint`) -- [ ] I have performed a self-review of my own code -- [ ] I have commented my code, particularly in hard-to-understand areas -- [ ] My changes generate no new warnings -- [ ] Any dependent changes have been merged and published - -## Configuration Changes - -If this PR changes configuration options: - -- [ ] I have updated example config files (`openrouter.example.yml`, `.env.example`) -- [ ] I have updated the README.md with new configuration options -- [ ] I have updated CLAUDE.md if the changes affect development workflow - -## Additional Notes - -Any additional information, context, or screenshots that would be helpful for reviewers. \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4dc7de9..7a4a9d2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -98,8 +98,6 @@ jobs: ./athena start --help ./athena stop --help ./athena status --help - ./athena logs --help - ./athena code --help # Test version/help flags work ./athena --version || true # May not have version flag @@ -117,8 +115,6 @@ jobs: ./athena.exe start --help ./athena.exe stop --help ./athena.exe status --help - ./athena.exe logs --help - ./athena.exe code --help echo "✅ CLI commands validated on Windows" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6ce5e72..523967e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -129,7 +129,7 @@ jobs: 2. Make it executable: `chmod +x athena-` 3. Copy example config: `cp athena.example.yml ~/.config/athena/athena.yml` 4. Edit config with your OpenRouter API key - 5. Run: `./athena- code` (launches daemon + Claude Code) + 5. Run: `./athena- start` to launch daemon ## Files @@ -147,17 +147,11 @@ jobs: ## Usage - ### Launch daemon + Claude Code: - ```bash - ./athena- code - ``` - ### Daemon management: ```bash ./athena- start # Start daemon in background ./athena- stop # Stop daemon ./athena- status # Check daemon status - ./athena- logs # View daemon logs ``` ### Run server directly (foreground): @@ -183,17 +177,16 @@ jobs: - Tool/function calling support - Configurable model mappings (Opus, Sonnet, Haiku) - Multiple configuration methods (CLI, config files, env vars) - - Built-in daemon management (start, stop, status, logs) - - Integrated Claude Code launcher (`athena code`) - - Structured logging with rotation + - Built-in daemon management (start, stop, status) + - Structured logging ### Quick Start ```bash # Download binary for your platform chmod +x athena- - # Launch daemon + Claude Code - ./athena- code + # Launch daemon + ./athena- start ``` ### Downloads diff --git a/.gitignore b/.gitignore index 41a8e8d..33fec67 100644 --- a/.gitignore +++ b/.gitignore @@ -67,3 +67,5 @@ coverage.html *.tmp temp/ tmp/ + +worktrees/ diff --git a/CLAUDE.md b/CLAUDE.md index 4613758..0a87ad1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Project Overview -Athena is a Go-based HTTP proxy server that translates Anthropic API requests to OpenRouter format, enabling Claude Code to work with OpenRouter's diverse model selection. The application uses minimal external dependencies (Cobra CLI framework, YAML parser, log rotation) and follows standard Go project layout with `cmd/` and `internal/` packages. +Athena is a Go-based HTTP proxy server that translates Anthropic API requests to OpenRouter format, enabling Claude Code to work with OpenRouter's diverse model selection. The application uses minimal external dependencies (Cobra CLI framework, YAML parser) and follows standard Go project layout with `cmd/` and `internal/` packages. **Status**: Production-ready with all core features implemented and tested. @@ -86,25 +86,79 @@ make build ./athena -port 9000 -api-key YOUR_KEY ``` +### CLI Commands + +Athena provides a simple 4-command interface: + +```bash +# Run server in foreground (default) +athena + +# Start server as background daemon +athena start + +# Stop the daemon +athena stop + +# Show daemon status +athena status +``` + +Logs are written to `~/.athena/athena.log` in daemon mode. View with standard tools: +```bash +# Follow logs in real-time +tail -f ~/.athena/athena.log + +# Search logs +grep "error" ~/.athena/athena.log + +# View last 100 lines +tail -n 100 ~/.athena/athena.log +``` + +**Log Levels:** +- `info` (default): High-level request/response metadata without bodies +- `debug`: Full request/response bodies logged (useful for troubleshooting) +- `warn`: Warnings and errors only +- `error`: Errors only + +```bash +# Enable debug logging +athena --log-level debug + +# Or via config file +# athena.yml: +log_level: "debug" + +# Or via environment variable +export ATHENA_LOG_LEVEL=debug +athena start +``` + ### Testing the Proxy ```bash # Health check -curl http://localhost:11434/health +curl http://localhost:12377/health # Test message endpoint -curl -X POST http://localhost:11434/v1/messages \ +curl -X POST http://localhost:12377/v1/messages \ -H "Content-Type: application/json" \ -H "X-Api-Key: your-openrouter-key" \ -d '{"model":"claude-3-sonnet","messages":[{"role":"user","content":"Hello"}]}' ``` ### Configuration Management -The configuration system follows this priority: CLI flags > config files > env vars > defaults - -Config files searched in order: -- `~/.config/athena/athena.{yml,json}` -- `./athena.{yml,json}` -- `./.env` (environment variables) +The configuration system follows this priority (highest to lowest): +1. **CLI flags** (via --flag options) +2. **Environment variables** (ATHENA_* prefixed) +3. **Local config file** (./athena.yml in current directory) +4. **Global config file** (~/.config/athena/athena.yml) +5. **Defaults** (hardcoded in config.go) + +Config file discovery: +- If `--config` flag is provided, only that file is loaded +- Otherwise, both global and local configs are discovered and merged (local overrides global) +- Runtime state (PID file, logs) stored in `~/.athena/` ## Key Implementation Details @@ -180,7 +234,7 @@ haiku_model: "google/gemini-pro" # Fast/cheap ### Local development with Ollama ```yaml -base_url: "http://localhost:11434/v1" +base_url: "http://localhost:12377/v1" opus_model: "llama3:70b" sonnet_model: "llama3:8b" ``` diff --git a/README.md b/README.md index c2706f7..06ed0a7 100644 --- a/README.md +++ b/README.md @@ -16,8 +16,7 @@ A proxy server that maps Anthropic's API format to OpenAI API format, allowing y - 🎯 **Model Mapping**: Configurable mappings for Opus, Sonnet, and Haiku models - 🔀 **Provider Routing**: Automatic Groq provider routing for Kimi K2 models - ⚙️ **Flexible Configuration**: CLI flags, config files, environment variables, and .env files -- 🖥️ **Claude Code Integration**: Built-in launcher for seamless Claude Code TUI experience -- 🚀 **Minimal Dependencies**: Lightweight with only essential external packages (Cobra CLI, YAML, log rotation) +- 🚀 **Minimal Dependencies**: Lightweight with only essential external packages (Cobra CLI, YAML parser) ## Quick Start @@ -33,16 +32,17 @@ curl -fsSL https://raw.githubusercontent.com/martinffx/athena/main/install.sh | ## Configuration -The proxy looks for configuration in this priority order: -1. Command line flags (highest priority) -2. Config files: `~/.config/athena/athena.{yml,json}` or `./athena.{yml,json}` -3. Environment variables (including `.env` file) -4. Built-in defaults (lowest priority) +The proxy looks for configuration in this priority order (highest to lowest): +1. **Command line flags** - CLI arguments override everything +2. **Environment variables** - ATHENA_* prefixed env vars +3. **Local config file** - `./athena.yml` in current directory +4. **Global config file** - `~/.config/athena/athena.yml` +5. **Built-in defaults** - Hardcoded fallback values ### Config File Example (YAML): ```yaml # ~/.config/athena/athena.yml -port: "11434" +port: "12377" api_key: "your-openrouter-api-key-here" base_url: "https://openrouter.ai/api" model: "moonshotai/kimi-k2-0905" @@ -58,7 +58,7 @@ haiku_model: "anthropic/claude-3.5-haiku" For fine-grained control over provider routing, add provider configurations to your YAML config: ```yaml -port: "11434" +port: "12377" api_key: "your-openrouter-api-key-here" base_url: "https://openrouter.ai/api" model: "moonshotai/kimi-k2-0905" @@ -87,7 +87,7 @@ export OPUS_MODEL="anthropic/claude-3-opus" export SONNET_MODEL="anthropic/claude-3.5-sonnet" export HAIKU_MODEL="anthropic/claude-3.5-haiku" export DEFAULT_MODEL="moonshotai/kimi-k2-0905" -export PORT="11434" +export PORT="12377" ``` ### .env File: @@ -101,26 +101,49 @@ HAIKU_MODEL=anthropic/claude-3.5-haiku ## Usage -### Option 1: Just the proxy server +### Daemon Management + ```bash -# Start the proxy server -athena -api-key YOUR_OPENROUTER_KEY +# Run in foreground (default) +athena -# In another terminal, configure Claude Code -export ANTHROPIC_BASE_URL=http://localhost:11434 -export ANTHROPIC_API_KEY=YOUR_OPENROUTER_KEY -claude +# Run as background daemon +athena start + +# Stop daemon +athena stop + +# Check daemon status +athena status + +# View logs (daemon mode) +tail -f ~/.athena/athena.log ``` -### Option 2: Custom configuration +### Custom Configuration ```bash -# Use specific models and port -athena \ - -port 9000 \ - -api-key YOUR_KEY \ - -opus-model "openai/gpt-4" \ - -sonnet-model "google/gemini-pro" \ - -haiku-model "meta-llama/llama-2-13b-chat" +# Use specific models and port (foreground) +athena -port 9000 -api-key YOUR_KEY + +# Or run as daemon with custom port +athena start -port 9000 -api-key YOUR_KEY + +# Enable debug logging to see full request/response bodies +athena --log-level debug +``` + +### Using with Claude Code + +```bash +# Start Athena daemon +athena start + +# Configure Claude Code to use the proxy +export ANTHROPIC_BASE_URL=http://localhost:12377 +export ANTHROPIC_API_KEY=your-openrouter-key + +# Run Claude Code +claude ``` ## How It Works @@ -192,7 +215,7 @@ haiku_model: "google/gemini-pro" ### Use Claude Code with Local Ollama: ```yaml -base_url: "http://localhost:11434/v1" +base_url: "http://localhost:12377/v1" opus_model: "llama3:70b" sonnet_model: "llama3:8b" haiku_model: "llama3:8b" @@ -212,15 +235,12 @@ athena -port 9000 echo $OPENROUTER_API_KEY # Test the proxy directly -curl -X POST http://localhost:11434/v1/messages \ +curl -X POST http://localhost:12377/v1/messages \ -H "Content-Type: application/json" \ -H "X-Api-Key: your-key" \ -d '{"model":"claude-3-sonnet","messages":[{"role":"user","content":"Hi"}]}' ``` -### Claude Code not found: -Install Claude Code from: https://claude.ai/code - ## License MIT License - see [LICENSE](LICENSE) for details. diff --git a/athena b/athena index 63b581d..dee56b3 100755 Binary files a/athena and b/athena differ diff --git a/athena.example.yml b/athena.example.yml index e4325f0..8d57335 100644 --- a/athena.example.yml +++ b/athena.example.yml @@ -1,14 +1,28 @@ # OpenRouter Proxy Configuration -port: "11434" +# +# This file can be placed in: +# - ~/.config/athena/athena.yml (global configuration) +# - ./athena.yml (project-specific configuration, overrides global) +# +# Configuration priority (highest to lowest): +# 1. CLI flags (--port, --api-key, etc.) +# 2. Environment variables (ATHENA_PORT, ATHENA_API_KEY, etc.) +# 3. Local config file (./athena.yml) +# 4. Global config file (~/.config/athena/athena.yml) +# 5. Built-in defaults + +port: "12377" api_key: "your-openrouter-api-key-here" base_url: "https://openrouter.ai/api" model: "moonshotai/kimi-k2-0905" -opus_model: "anthropic/claude-3-opus" -sonnet_model: "anthropic/claude-3.5-sonnet" -haiku_model: "anthropic/claude-3.5-haiku" + +# opus_model: "deepseek/deepseek-v3.1-terminus" +# sonnet_model: "qwen/qwen3-coder" +# haiku_model: "qwen/qwen3-next-80b-a3b-instruct" # Logging configuration -log_format: "text" # "text" or "json" +log_format: "text" # "text" or "json" +log_level: "info" # "debug", "info", "warn", or "error" (set to "debug" to log full request/response bodies) # log_file: "/path/to/athena.log" # Optional: log file path (default: stdout, daemon uses ~/.athena/athena.log) # Provider routing is configured automatically for kimi-k2 models via Groq @@ -19,5 +33,5 @@ log_format: "text" # "text" or "json" # allow_fallbacks: false # opus_provider: # order: -# - Anthropic -# allow_fallbacks: true \ No newline at end of file +# - cerebras/fp8 +# allow_fallbacks: true diff --git a/docs/specs/openaiproxy/spec.md b/docs/specs/openaiproxy/spec.md index bb0ef7f..b67d815 100644 --- a/docs/specs/openaiproxy/spec.md +++ b/docs/specs/openaiproxy/spec.md @@ -96,11 +96,13 @@ As a Claude Code user, I want to use the proxy to connect to OpenRouter with dif 5. Return to Client ### Configuration -- Priority: CLI flags > config files > env vars > defaults -- Search paths: - - `~/.config/athena/athena.{yml,json}` - - `./athena.{yml,json}` - - `./.env` +- Priority (highest to lowest): CLI flags > env vars > local config > global config > defaults +- Config file discovery: + - Global: `~/.config/athena/athena.yml` + - Local: `./athena.yml` (overrides global) + - Explicit: `--config ` (bypasses discovery) +- Environment variables: `ATHENA_*` prefixed +- Runtime state directory: `~/.athena/` (PID file, logs) ### Performance Targets - Transformation Latency: <1ms diff --git a/docs/specs/subcommands/spec.md b/docs/specs/subcommands/spec.md index 4f047fa..2313d50 100644 --- a/docs/specs/subcommands/spec.md +++ b/docs/specs/subcommands/spec.md @@ -7,12 +7,12 @@ ## Feature Overview ### User Story -**As a** developer using openrouter-cc proxy -**I want to** control the proxy server with CLI subcommands (start, stop, status, logs, code) -**So that I can** manage the proxy as a background service and integrate seamlessly with Claude Code workflows +**As a** developer using athena proxy +**I want to** control the proxy server with CLI subcommands (start, stop, status) +**So that I can** manage the proxy as a background service ### Business Context -OpenRouter CC currently operates as a foreground HTTP proxy server. Users must manage the process manually using shell job control (`&`, `kill`, `ps`). This feature adds proper daemon management capabilities with five subcommands for process control, monitoring, and Claude Code integration. +Athena operates as an HTTP proxy server. This feature provides daemon management capabilities with four subcommands for process control and monitoring (the default command runs the server in foreground). ## Detailed Acceptance Criteria @@ -46,24 +46,6 @@ OpenRouter CC currently operates as a foreground HTTP proxy server. Users must m - Shows uptime duration if running - Shows last startup time from log file -### AC-04: Logs Command Streams Real-Time Output -**GIVEN** the proxy daemon is generating logs -**WHEN** user runs `openrouter-cc logs` -**THEN:** -- Displays existing log content -- Streams new log entries in real-time -- Handles log rotation gracefully -- Exits cleanly on Ctrl+C - -### AC-05: Code Command Launches Claude Code with Environment -**GIVEN** the proxy daemon is running -**WHEN** user runs `openrouter-cc code` -**THEN:** -- Sets `ANTHROPIC_API_KEY` environment variable to 'dummy' -- Sets `ANTHROPIC_BASE_URL` to proxy's local address -- Launches 'claude' command with inherited environment -- Proxy continues running in background -- Returns Claude Code's exit status ## Business Rules and Constraints @@ -89,11 +71,9 @@ All subcommands must work on Linux, macOS, and Windows to maintain existing plat ### Included Features - Cobra CLI framework integration -- Five subcommands: start, stop, status, logs, code +- Four subcommands: start, stop, status (default command runs server in foreground) - Daemon process management with PID file tracking -- Log file management with 10MB rotation - Cross-platform signal handling (SIGTERM/SIGINT) -- Environment variable setup for Claude Code integration - Migration of existing flag-based CLI to Cobra structure - Backward compatibility for all current CLI flags @@ -361,10 +341,10 @@ internal/ ## Definition of Done -- [ ] All five subcommands (start, stop, status, logs, code) implemented and tested -- [ ] Backward compatibility maintained for all existing CLI flags and behavior -- [ ] Cross-platform functionality verified on Linux, macOS, and Windows -- [ ] Comprehensive test suite with >90% code coverage -- [ ] Performance benchmarks meet specified targets -- [ ] Documentation updated with subcommand usage examples -- [ ] CI/CD pipeline updated for Cobra dependency and cross-platform testing \ No newline at end of file +- [x] All four subcommands (start, stop, status) implemented and tested +- [x] Backward compatibility maintained for all existing CLI flags and behavior +- [x] Cross-platform functionality verified on Linux, macOS, and Windows +- [x] Comprehensive test suite with >90% code coverage +- [x] Performance benchmarks meet specified targets +- [x] Documentation updated with subcommand usage examples +- [x] CI/CD pipeline updated for Cobra dependency and cross-platform testing \ No newline at end of file diff --git a/install.sh b/install.sh index da4270d..061e8bf 100644 --- a/install.sh +++ b/install.sh @@ -182,10 +182,22 @@ main() { success "Installation complete!" echo log "Next steps:" - echo "1. Copy example config: cp $CONFIG_DIR/athena.example.yml $CONFIG_DIR/athena.yml" - echo "2. Edit config with your OpenRouter API key" - echo "3. Run: athena code (launches daemon + Claude Code)" - echo " Or: athena start (daemon only)" + echo "1. Set up global config:" + echo " cp $CONFIG_DIR/athena.example.yml $CONFIG_DIR/athena.yml" + echo "2. Edit with your OpenRouter API key:" + echo " vim $CONFIG_DIR/athena.yml" + echo "3. (Optional) Create project-specific config in your project directory:" + echo " cp $CONFIG_DIR/athena.example.yml ./athena.yml" + echo + echo "4. Run the server:" + echo " athena # Foreground mode" + echo " athena start # Daemon mode" + echo + echo "5. (Optional) Configure Claude Code to use the proxy:" + echo " export ANTHROPIC_BASE_URL=http://localhost:12377" + echo " claude" + echo + log "Config priority: CLI flags > env vars > ./athena.yml > ~/.config/athena/athena.yml > defaults" echo log "For more information, see: https://github.com/$REPO" } diff --git a/internal/cli.go b/internal/cli.go index 5a04c84..72a95aa 100644 --- a/internal/cli.go +++ b/internal/cli.go @@ -1,10 +1,10 @@ // Package internal provides the command-line interface for Athena using Cobra. -// It implements subcommands for daemon management (start, stop, status, logs) -// and Claude Code integration. +// It implements subcommands for daemon management (start, stop, status). package internal import ( "fmt" + "log/slog" "os" "time" @@ -12,6 +12,7 @@ import ( "athena/internal/config" "athena/internal/daemon" + "athena/internal/server" ) var ( @@ -25,13 +26,12 @@ var ( sonnetModel string haikuModel string logFormat string + logLevel string logFile string // Command-specific flags statusJSON bool stopTimeout time.Duration - logsLines int - logsFollow bool ) // rootCmd represents the base command when called without any subcommands @@ -42,15 +42,29 @@ var rootCmd = &cobra.Command{ to OpenRouter format, enabling Claude Code to work with OpenRouter's diverse model selection. -By default, running 'athena' will start the daemon and launch Claude Code. -Use subcommands for daemon management (start, stop, status, logs).`, - // Default behavior: Start daemon and launch Claude Code - RunE: func(_ *cobra.Command, args []string) error { +By default, runs the HTTP server in foreground mode. +Use 'athena start' to run as a background daemon.`, + // Initialize logger before running any command + PersistentPreRunE: func(_ *cobra.Command, _ []string) error { + // Try to load config, but don't fail if API key is missing + // (some commands like stop/status don't need it) + cfg, err := config.New(configFile) + if err != nil { + return fmt.Errorf("failed to load config: %w", err) + } + applyFlagOverrides(cfg) + initLogger(cfg) + return nil + }, + // Default behavior: Run server in foreground + RunE: func(_ *cobra.Command, _ []string) error { cfg, err := loadAndValidateConfig() if err != nil { return err } - return daemon.LaunchWithClaude(cfg, args) + + srv := server.New(cfg) + return srv.Start() }, } @@ -110,49 +124,6 @@ var statusCmd = &cobra.Command{ }, } -// logsCmd shows daemon logs -var logsCmd = &cobra.Command{ - Use: "logs", - Short: "Show Athena daemon logs", - Long: `Display logs from the Athena daemon. - -By default, tails the log file in real-time (like tail -f). -Use --follow=false to show last N lines and exit.`, - RunE: func(_ *cobra.Command, _ []string) error { - logPath, err := daemon.GetLogFilePath() - if err != nil { - return fmt.Errorf("failed to get log file path: %w", err) - } - - if _, err := os.Stat(logPath); os.IsNotExist(err) { - return fmt.Errorf("log file not found: %s (daemon may not have been started)", logPath) - } - - if logsFollow { - return daemon.FollowLogs(logPath) - } - - return daemon.ShowLastLines(logPath, logsLines) - }, -} - -// codeCmd explicitly starts daemon and launches Claude Code -var codeCmd = &cobra.Command{ - Use: "code [args...]", - Short: "Start daemon and launch Claude Code", - Long: `Start the Athena daemon (if not running) and launch Claude Code with -the correct environment variables configured automatically. - -Any additional arguments are passed through to the claude command.`, - RunE: func(_ *cobra.Command, args []string) error { - cfg, err := loadAndValidateConfig() - if err != nil { - return err - } - return daemon.LaunchWithClaude(cfg, args) - }, -} - func init() { // Persistent flags available to all commands rootCmd.PersistentFlags().StringVar(&configFile, "config", "", "Path to config file (YAML)") @@ -164,20 +135,17 @@ func init() { rootCmd.PersistentFlags().StringVar(&sonnetModel, "model-sonnet", "", "Model to map claude-sonnet requests to") rootCmd.PersistentFlags().StringVar(&haikuModel, "model-haiku", "", "Model to map claude-haiku requests to") rootCmd.PersistentFlags().StringVar(&logFormat, "log-format", "", "Log format: text or json") + rootCmd.PersistentFlags().StringVar(&logLevel, "log-level", "", "Log level: debug, info, warn, error") rootCmd.PersistentFlags().StringVar(&logFile, "log-file", "", "Log file path (default: stdout)") // Command-specific flags statusCmd.Flags().BoolVar(&statusJSON, "json", false, "Output status as JSON") stopCmd.Flags().DurationVar(&stopTimeout, "timeout", 30*time.Second, "Graceful shutdown timeout") - logsCmd.Flags().IntVarP(&logsLines, "lines", "n", 50, "Number of lines to show (when not following)") - logsCmd.Flags().BoolVarP(&logsFollow, "follow", "f", true, "Follow log output (tail -f behavior)") // Add subcommands rootCmd.AddCommand(startCmd) rootCmd.AddCommand(stopCmd) rootCmd.AddCommand(statusCmd) - rootCmd.AddCommand(logsCmd) - rootCmd.AddCommand(codeCmd) } // Execute adds all child commands to the root command and sets flags appropriately. @@ -228,7 +196,41 @@ func applyFlagOverrides(cfg *config.Config) { if logFormat != "" { cfg.LogFormat = logFormat } + if logLevel != "" { + cfg.LogLevel = logLevel + } if logFile != "" { cfg.LogFile = logFile } } + +// initLogger initializes the global slog logger with the configured level +func initLogger(cfg *config.Config) { + var level slog.Level + switch cfg.LogLevel { + case "debug": + level = slog.LevelDebug + case "info": + level = slog.LevelInfo + case "warn": + level = slog.LevelWarn + case "error": + level = slog.LevelError + default: + level = slog.LevelInfo + } + + opts := &slog.HandlerOptions{ + Level: level, + } + + var handler slog.Handler + if cfg.LogFormat == "json" { + handler = slog.NewJSONHandler(os.Stdout, opts) + } else { + handler = slog.NewTextHandler(os.Stdout, opts) + } + + logger := slog.New(handler) + slog.SetDefault(logger) +} diff --git a/internal/config/config.go b/internal/config/config.go index 1b34145..70260ea 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -9,14 +9,14 @@ import ( // Default configuration values const ( DefaultModelName = "moonshotai/kimi-k2-0905" - DefaultPort = "11434" + DefaultPort = "12377" DefaultBaseURL = "https://openrouter.ai/api" ) // ProviderConfig holds provider routing configuration type ProviderConfig struct { - Order []string `yaml:"order"` - AllowFallbacks bool `yaml:"allow_fallbacks"` + Order []string `yaml:"order" json:"order"` + AllowFallbacks bool `yaml:"allow_fallbacks" json:"allow_fallbacks"` } // Config holds the application configuration @@ -33,10 +33,11 @@ type Config struct { SonnetProvider *ProviderConfig `yaml:"sonnet_provider,omitempty"` HaikuProvider *ProviderConfig `yaml:"haiku_provider,omitempty"` LogFormat string `yaml:"log_format"` + LogLevel string `yaml:"log_level,omitempty"` LogFile string `yaml:"log_file,omitempty"` } -// New creates a new Config with precedence: env vars > config file > defaults +// New creates a new Config with precedence: env vars > ./athena.yml > ~/.config/athena/athena.yml > defaults func New(configPath string) (*Config, error) { // 1. Start with hard-coded defaults cfg := &Config{ @@ -44,16 +45,29 @@ func New(configPath string) (*Config, error) { BaseURL: DefaultBaseURL, Model: DefaultModelName, LogFormat: "text", + LogLevel: "info", } - // 2. Load and merge YAML config file (if provided) + // 2. Discover and load config files (if not explicitly provided) + var paths []string if configPath != "" { - data, err := os.ReadFile(configPath) - if err != nil { - return nil, err - } - if err := yaml.Unmarshal(data, cfg); err != nil { - return nil, err + // Explicit config path takes precedence over discovery + paths = []string{configPath} + } else { + // Discover config files in priority order: global, then local + paths = discoverConfigFiles() + } + + // Load config files in reverse priority order (global first, local last) + // This ensures local configs override global configs + for _, path := range paths { + if err := loadConfigFile(path, cfg); err != nil { + if configPath != "" { + // If explicit config was specified, fail on error + return nil, err + } + // For discovered configs, skip if file doesn't exist or has errors + continue } } @@ -82,9 +96,46 @@ func New(configPath string) (*Config, error) { if v := os.Getenv("ATHENA_LOG_FORMAT"); v != "" { cfg.LogFormat = v } + if v := os.Getenv("ATHENA_LOG_LEVEL"); v != "" { + cfg.LogLevel = v + } if v := os.Getenv("ATHENA_LOG_FILE"); v != "" { cfg.LogFile = v } return cfg, nil } + +// discoverConfigFiles returns a list of config file paths in priority order +// Priority: ~/.config/athena/athena.yml (global) → ./athena.yml (local) +func discoverConfigFiles() []string { + var paths []string + + // 1. Global config in ~/.config/athena/athena.yml + if home, err := os.UserHomeDir(); err == nil { + globalPath := home + "/.config/athena/athena.yml" + if _, err := os.Stat(globalPath); err == nil { + paths = append(paths, globalPath) + } + } + + // 2. Local config in ./athena.yml + localPath := "./athena.yml" + if _, err := os.Stat(localPath); err == nil { + paths = append(paths, localPath) + } + + return paths +} + +// loadConfigFile loads and merges a YAML config file into the provided config +func loadConfigFile(path string, cfg *Config) error { + data, err := os.ReadFile(path) + if err != nil { + return err + } + if err := yaml.Unmarshal(data, cfg); err != nil { + return err + } + return nil +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 6768bdb..7d23118 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -359,3 +359,162 @@ func TestNew_LogFormat(t *testing.T) { t.Errorf("LogFormat = %q, expected %q", cfg.LogFormat, "json") } } + +func TestNew_ConfigDiscovery(t *testing.T) { + // Save original directory and restore at the end + origDir, err := os.Getwd() + if err != nil { + t.Fatalf("Failed to get working directory: %v", err) + } + defer func() { + if chdirErr := os.Chdir(origDir); chdirErr != nil { + t.Logf("Failed to restore directory: %v", chdirErr) + } + }() + + // Create a temp directory and change to it + tmpDir := t.TempDir() + if chdirErr := os.Chdir(tmpDir); chdirErr != nil { + t.Fatalf("Failed to change to temp directory: %v", chdirErr) + } + + // Create local config file + localContent := `port: "7777" +api_key: "local-key" +model: "local/model" +` + if writeErr := os.WriteFile("athena.yml", []byte(localContent), 0644); writeErr != nil { + t.Fatalf("Failed to write local config: %v", writeErr) + } + + // Clear env vars + os.Unsetenv("ATHENA_PORT") + os.Unsetenv("ATHENA_API_KEY") + os.Unsetenv("ATHENA_MODEL") + + // Test that local config is discovered and loaded + cfg, err := New("") + if err != nil { + t.Fatalf("New() failed: %v", err) + } + + if cfg.Port != "7777" { + t.Errorf("Port = %q, expected %q (from discovered local config)", cfg.Port, "7777") + } + if cfg.APIKey != "local-key" { + t.Errorf("APIKey = %q, expected %q (from discovered local config)", cfg.APIKey, "local-key") + } + if cfg.Model != "local/model" { + t.Errorf("Model = %q, expected %q (from discovered local config)", cfg.Model, "local/model") + } +} + +func TestNew_PrecedenceLocalOverridesGlobal(t *testing.T) { + // This test verifies the precedence: env > local > global > defaults + // We'll simulate a global config by creating it in a temp location + // and a local config that overrides some values + + // Save original directory + origDir, err := os.Getwd() + if err != nil { + t.Fatalf("Failed to get working directory: %v", err) + } + defer func() { + if chdirErr := os.Chdir(origDir); chdirErr != nil { + t.Logf("Failed to restore directory: %v", chdirErr) + } + }() + + // Create temp directory for test + tmpDir := t.TempDir() + if chdirErr := os.Chdir(tmpDir); chdirErr != nil { + t.Fatalf("Failed to change to temp directory: %v", chdirErr) + } + + // Create local config that overrides some values + localContent := `port: "8888" +api_key: "local-key" +` + if writeErr := os.WriteFile("athena.yml", []byte(localContent), 0644); writeErr != nil { + t.Fatalf("Failed to write local config: %v", writeErr) + } + + // Clear env vars + os.Unsetenv("ATHENA_PORT") + os.Unsetenv("ATHENA_API_KEY") + os.Unsetenv("ATHENA_MODEL") + + cfg, err := New("") + if err != nil { + t.Fatalf("New() failed: %v", err) + } + + // Port should come from local config + if cfg.Port != "8888" { + t.Errorf("Port = %q, expected %q (from local config)", cfg.Port, "8888") + } + + // API key should come from local config + if cfg.APIKey != "local-key" { + t.Errorf("APIKey = %q, expected %q (from local config)", cfg.APIKey, "local-key") + } + + // Model not set in local, should use default + if cfg.Model != DefaultModelName { + t.Errorf("Model = %q, expected default %q", cfg.Model, DefaultModelName) + } +} + +func TestNew_EnvOverridesDiscoveredConfig(t *testing.T) { + // Save original directory + origDir, err := os.Getwd() + if err != nil { + t.Fatalf("Failed to get working directory: %v", err) + } + defer func() { + if chdirErr := os.Chdir(origDir); chdirErr != nil { + t.Logf("Failed to restore directory: %v", chdirErr) + } + }() + + // Create temp directory + tmpDir := t.TempDir() + if chdirErr := os.Chdir(tmpDir); chdirErr != nil { + t.Fatalf("Failed to change to temp directory: %v", chdirErr) + } + + // Create local config + localContent := `port: "9999" +api_key: "file-key" +model: "file/model" +` + if writeErr := os.WriteFile("athena.yml", []byte(localContent), 0644); writeErr != nil { + t.Fatalf("Failed to write local config: %v", writeErr) + } + + // Set env vars (should override discovered config) + os.Setenv("ATHENA_PORT", "7000") + os.Setenv("ATHENA_API_KEY", "env-key") + defer func() { + os.Unsetenv("ATHENA_PORT") + os.Unsetenv("ATHENA_API_KEY") + }() + + cfg, err := New("") + if err != nil { + t.Fatalf("New() failed: %v", err) + } + + // Env should override file + if cfg.Port != "7000" { + t.Errorf("Port = %q, expected env value %q (env overrides discovered config)", cfg.Port, "7000") + } + if cfg.APIKey != "env-key" { + t.Errorf("APIKey = %q, expected env value %q (env overrides discovered config)", cfg.APIKey, "env-key") + } + + // Model not set in env, should use file + if cfg.Model != "file/model" { + t.Errorf("Model = %q, expected file value %q", cfg.Model, "file/model") + } +} diff --git a/internal/daemon/daemon.go b/internal/daemon/daemon.go index 80e2d39..5df5960 100644 --- a/internal/daemon/daemon.go +++ b/internal/daemon/daemon.go @@ -62,7 +62,7 @@ func StartDaemon(cfg *config.Config) error { return fmt.Errorf("failed to get executable path: %w", err) } - // Build command arguments + // Build command arguments (default command runs server) args := []string{} if cfg.Port != "" { args = append(args, "--port", cfg.Port) diff --git a/internal/daemon/logs.go b/internal/daemon/logs.go deleted file mode 100644 index eabffa7..0000000 --- a/internal/daemon/logs.go +++ /dev/null @@ -1,87 +0,0 @@ -package daemon - -import ( - "bufio" - "fmt" - "os" - "time" -) - -const ( - // MaxLogLineLength is the maximum allowed length for a log line (1MB) - MaxLogLineLength = 1024 * 1024 - // LogPollInterval is how often to check for new log entries - LogPollInterval = 100 * time.Millisecond -) - -// ShowLastLines displays the last N lines from the log file -func ShowLastLines(logPath string, n int) error { - file, err := os.Open(logPath) - if err != nil { - return fmt.Errorf("failed to open log file: %w", err) - } - defer file.Close() - - // Read all lines into memory (simple approach for small log files) - var lines []string - scanner := bufio.NewScanner(file) - for scanner.Scan() { - lines = append(lines, scanner.Text()) - } - - if err := scanner.Err(); err != nil { - return fmt.Errorf("failed to read log file: %w", err) - } - - // Show last N lines - start := len(lines) - n - if start < 0 { - start = 0 - } - - for i := start; i < len(lines); i++ { - fmt.Println(lines[i]) - } - - return nil -} - -// FollowLogs tails the log file and streams new entries -func FollowLogs(logPath string) error { - file, err := os.Open(logPath) - if err != nil { - return fmt.Errorf("failed to open log file: %w", err) - } - defer file.Close() - - // Seek to end of file - _, err = file.Seek(0, 2) - if err != nil { - return fmt.Errorf("failed to seek to end of file: %w", err) - } - - scanner := bufio.NewScanner(file) - // Set buffer size limit to prevent unbounded memory growth - buf := make([]byte, 0, 64*1024) - scanner.Buffer(buf, MaxLogLineLength) - - for { - // Read available lines - for scanner.Scan() { - fmt.Println(scanner.Text()) - } - - if err := scanner.Err(); err != nil { - return fmt.Errorf("error reading log file: %w", err) - } - - // Wait a bit before checking for more lines - time.Sleep(LogPollInterval) - - // Check if daemon is still running - if !IsRunning() { - fmt.Println("\n[Daemon stopped]") - return nil - } - } -} diff --git a/internal/server/server.go b/internal/server/server.go index 027260f..9cc9fca 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -23,11 +23,25 @@ func New(cfg *config.Config) *Server { return &Server{cfg: cfg} } +// loggingMiddleware logs all incoming requests +func loggingMiddleware(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + slog.Info("incoming request", + "method", r.Method, + "path", r.URL.Path, + "remote_addr", r.RemoteAddr, + "user_agent", r.Header.Get("User-Agent"), + ) + next(w, r) + } +} + // Start starts the HTTP server func (s *Server) Start() error { - http.HandleFunc("/v1/messages", s.handleMessages) - http.HandleFunc("/health", s.handleHealth) + http.HandleFunc("/v1/messages", loggingMiddleware(s.handleMessages)) + http.HandleFunc("/health", loggingMiddleware(s.handleHealth)) + http.HandleFunc("/", loggingMiddleware(s.handleCatchAll)) slog.Info("starting server", "port", s.cfg.Port) @@ -50,6 +64,16 @@ func (s *Server) handleHealth(w http.ResponseWriter, _ *http.Request) { } } +func (s *Server) handleCatchAll(w http.ResponseWriter, r *http.Request) { + slog.Warn("unhandled request", + "method", r.Method, + "path", r.URL.Path, + "remote_addr", r.RemoteAddr, + "user_agent", r.Header.Get("User-Agent"), + ) + http.Error(w, "404 page not found", http.StatusNotFound) +} + func (s *Server) handleMessages(w http.ResponseWriter, r *http.Request) { start := time.Now() ctx := r.Context() @@ -66,6 +90,8 @@ func (s *Server) handleMessages(w http.ResponseWriter, r *http.Request) { return } + slog.Debug("request body", "body", string(body)) + var req transform.AnthropicRequest if unmarshalErr := json.Unmarshal(body, &req); unmarshalErr != nil { http.Error(w, "Invalid JSON", http.StatusBadRequest) @@ -100,6 +126,8 @@ func (s *Server) handleMessages(w http.ResponseWriter, r *http.Request) { return } + slog.Debug("transformed request", "body", string(openAIBody)) + // Forward to OpenRouter client := &http.Client{} url := s.cfg.BaseURL + "/v1/chat/completions" @@ -134,11 +162,33 @@ func (s *Server) handleMessages(w http.ResponseWriter, r *http.Request) { actualProvider = "unknown" } - slog.Info("response received", - "status", resp.StatusCode, - "duration_ms", duration.Milliseconds(), - "actual_provider", actualProvider, - ) + // Log high-level response info + if resp.StatusCode >= 400 { + // Read and log error responses with full body + bodyBytes, _ := io.ReadAll(resp.Body) + slog.Error("error response from OpenRouter", + "status", resp.StatusCode, + "duration_ms", duration.Milliseconds(), + "actual_provider", actualProvider, + "body", string(bodyBytes), + ) + // Recreate the body for downstream processing + resp.Body = io.NopCloser(bytes.NewReader(bodyBytes)) + } else { + // Log success at INFO level without body + slog.Info("response received", + "status", resp.StatusCode, + "duration_ms", duration.Milliseconds(), + "actual_provider", actualProvider, + ) + // Only read and log body at DEBUG level + if slog.Default().Enabled(ctx, slog.LevelDebug) { + bodyBytes, _ := io.ReadAll(resp.Body) + slog.Debug("response body", "body", string(bodyBytes)) + // Recreate the body for downstream processing + resp.Body = io.NopCloser(bytes.NewReader(bodyBytes)) + } + } // Handle response based on streaming if req.Stream {