diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5cb9e1b..7fa768a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -78,7 +78,7 @@ jobs: go-version: '1.24' - name: golangci-lint - uses: golangci/golangci-lint-action@v4 + uses: golangci/golangci-lint-action@v8 with: - version: latest + version: v2.5.0 args: --timeout=5m diff --git a/.golangci.yml b/.golangci.yml index e2b5eff..9f55c4f 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -1,48 +1,15 @@ +# golangci-lint v2 configuration +version: "2" + run: timeout: 5m - tests: true + tests: false # Don't analyze test files linters: + default: none # Start with no linters, only enable specific ones enable: - errcheck - - gosimple - govet - ineffassign - staticcheck - - unused - - gofmt - - goimports - -linters-settings: - errcheck: - check-blank: false - exclude-functions: - - fmt.Fprintf - - fmt.Fprintln - - (io.Closer).Close - - (*net/http.ResponseWriter).Write - - (*bufio.Writer).Flush - ignore: "fmt:.*,io:EOF" - gofmt: - simplify: true - -issues: - exclude-use-default: true - exclude-rules: - # Exclude error checks in test files - - path: _test\.go - linters: - - errcheck - # Exclude common patterns that are safe to ignore - - text: "Error return value of.*os\\.(Setenv|Unsetenv).*is not checked" - linters: - - errcheck - - text: "Error return value of.*json\\.Marshal.*is not checked" - linters: - - errcheck - - text: "Error return value of.*w\\.Flush.*is not checked" - linters: - - errcheck - - text: "Error return value of.*resp\\.Body\\.Close.*is not checked" - linters: - - errcheck + - unused \ No newline at end of file diff --git a/cmd/claude-code-proxy/main.go b/cmd/claude-code-proxy/main.go index 0dd08d7..0b41e09 100644 --- a/cmd/claude-code-proxy/main.go +++ b/cmd/claude-code-proxy/main.go @@ -100,7 +100,7 @@ Flags: Configuration: Config file locations (checked in order): - 1. ./​.env + 1. ./.env 2. ~/.claude/proxy.env 3. ~/.claude-code-proxy diff --git a/internal/config/config.go b/internal/config/config.go index 4438536..ac4550e 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -1,3 +1,8 @@ +// Package config handles configuration loading from environment variables and .env files. +// +// It supports multiple config file locations (./.env, ~/.claude/proxy.env, ~/.claude-code-proxy) +// and detects the provider type (OpenRouter, OpenAI, Ollama) based on the OPENAI_BASE_URL. +// The package also handles model overrides for routing Claude model names to alternative providers. package config import ( diff --git a/internal/converter/converter.go b/internal/converter/converter.go index 2ddfae1..3499f46 100644 --- a/internal/converter/converter.go +++ b/internal/converter/converter.go @@ -1,3 +1,8 @@ +// Package converter handles bidirectional conversion between Claude and OpenAI API formats. +// +// It provides functions to convert Claude API requests to OpenAI-compatible format and +// OpenAI responses back to Claude format. This includes mapping models, converting message +// structures, handling tool calls, and extracting thinking blocks from reasoning responses. package converter import ( @@ -20,7 +25,9 @@ const ( DefaultHaikuModel = "gpt-5-mini" ) -// extractSystemText extracts system text from either string or array format +// extractSystemText extracts system text from Claude's flexible system parameter. +// Claude supports both string format ("system": "text") and array format with content blocks. +// This function normalizes both formats to a single string for OpenAI compatibility. func extractSystemText(system interface{}) string { if system == nil { return "" @@ -100,7 +107,9 @@ func ConvertRequest(claudeReq models.ClaudeRequest, cfg *config.Config) (*models switch provider { case config.ProviderOpenRouter: - // OpenRouter-specific format + // OpenRouter needs reasoning blocks and usage tracking enabled + // - reasoning.enabled: Enables thinking blocks in response + // - usage.include: Tracks token usage even in streaming mode openaiReq.StreamOptions = map[string]interface{}{ "include_usage": true, } @@ -112,19 +121,17 @@ func ConvertRequest(claudeReq models.ClaudeRequest, cfg *config.Config) (*models } case config.ProviderOpenAI: - // OpenAI supports stream_options and reasoning (GPT-5 models) + // OpenAI GPT-5 models support reasoning_effort parameter + // This controls how much time the model spends thinking before responding openaiReq.StreamOptions = map[string]interface{}{ "include_usage": true, } - // GPT-5 models: Use Chat Completions reasoning_effort parameter openaiReq.ReasoningEffort = "medium" // minimal | low | medium | high case config.ProviderOllama: - // Force Ollama to use tools when they're provided - // Check claudeReq.Tools since openaiReq.Tools hasn't been set yet + // Ollama needs explicit tool_choice when tools are present + // Without this, Ollama models may not naturally choose to use tools if len(claudeReq.Tools) > 0 { - // Set tool_choice to "required" to force tool usage - // This helps with models that don't naturally choose to use tools openaiReq.ToolChoice = "required" } } @@ -153,7 +160,10 @@ func ConvertRequest(claudeReq models.ClaudeRequest, cfg *config.Config) (*models return openaiReq, nil } -// mapModel implements pattern-based model routing +// mapModel maps Claude model names to provider-specific models using pattern matching. +// It routes haiku/sonnet/opus tiers to appropriate models (gpt-5-mini, gpt-5, etc.) +// and allows environment variable overrides for routing to alternative providers like +// Grok, Gemini, or DeepSeek. Non-Claude model names are passed through unchanged. func mapModel(claudeModel string, cfg *config.Config) string { modelLower := strings.ToLower(claudeModel) @@ -185,7 +195,15 @@ func mapModel(claudeModel string, cfg *config.Config) string { return claudeModel } -// convertMessages converts Claude messages to OpenAI format +// convertMessages converts Claude messages to OpenAI format. +// +// Handles three content types: +// - String content: Simple text messages +// - Array content with blocks: text, tool_use (mapped to tool_calls), and tool_result (mapped to role=tool) +// - Tool results: Special handling to create OpenAI tool response messages +// +// The function maintains the conversation flow while translating Claude's content block +// structure to OpenAI's message format, ensuring tool call IDs are preserved for correlation. func convertMessages(claudeMessages []models.ClaudeMessage, system string) []models.OpenAIMessage { openaiMessages := []models.OpenAIMessage{} @@ -314,7 +332,8 @@ func convertMessages(claudeMessages []models.ClaudeMessage, system string) []mod return openaiMessages } -// convertTools converts Claude tools to OpenAI format +// convertTools converts Claude tool definitions to OpenAI function calling format. +// Maps tool name, description, and input_schema to OpenAI's function structure. func convertTools(claudeTools []models.Tool) []models.OpenAITool { openaiTools := make([]models.OpenAITool, len(claudeTools)) diff --git a/internal/daemon/daemon.go b/internal/daemon/daemon.go index 058ebf4..452935c 100644 --- a/internal/daemon/daemon.go +++ b/internal/daemon/daemon.go @@ -1,3 +1,8 @@ +// Package daemon handles background process management for the proxy server. +// +// It manages PID file creation/deletion, process health checks, and provides functions +// to start, stop, and check the status of the proxy daemon. The daemon runs in the +// background and can be controlled via the CLI (start, stop, status commands). package daemon import ( @@ -18,7 +23,7 @@ func IsRunning() bool { // Try health check first resp, err := http.Get(healthURL) if err == nil { - resp.Body.Close() + _ = resp.Body.Close() return resp.StatusCode == 200 } @@ -100,7 +105,7 @@ func readPID() (int, error) { } func cleanupPID() { - os.Remove(pidFile) + _ = os.Remove(pidFile) // Ignore error - cleanup is best-effort } func isProcessRunning() bool { diff --git a/internal/server/handlers.go b/internal/server/handlers.go index f74378b..c83db00 100644 --- a/internal/server/handlers.go +++ b/internal/server/handlers.go @@ -16,6 +16,21 @@ import ( "github.com/gofiber/fiber/v2" ) +// addOpenRouterHeaders adds OpenRouter-specific HTTP headers for better rate limits. +// Sets HTTP-Referer and X-Title headers when configured, which helps with OpenRouter's +// rate limiting and usage tracking. +func addOpenRouterHeaders(req *http.Request, cfg *config.Config) { + if cfg.OpenRouterAppURL != "" { + req.Header.Set("HTTP-Referer", cfg.OpenRouterAppURL) + } + if cfg.OpenRouterAppName != "" { + req.Header.Set("X-Title", cfg.OpenRouterAppName) + } +} + +// handleMessages is the main handler for /v1/messages endpoint. +// It parses Claude requests, converts them to OpenAI format, and routes to either +// streaming or non-streaming handlers based on the request's stream parameter. func handleMessages(c *fiber.Ctx, cfg *config.Config) error { // Debug: Log raw request if cfg.Debug { @@ -163,7 +178,9 @@ func handleMessages(c *fiber.Ctx, cfg *config.Config) error { return c.JSON(claudeResp) } -// handleStreamingMessages handles streaming requests +// handleStreamingMessages handles streaming SSE responses from the provider. +// It forwards the OpenAI request, receives streaming chunks, and converts them to +// Claude's SSE event format in real-time using streamOpenAIToClaude. func handleStreamingMessages(c *fiber.Ctx, openaiReq *models.OpenAIRequest, cfg *config.Config) error { // Track timing for simple log startTime := time.Now() @@ -213,12 +230,7 @@ func handleStreamingMessages(c *fiber.Ctx, openaiReq *models.OpenAIRequest, cfg // OpenRouter-specific headers for better rate limits if cfg.DetectProvider() == config.ProviderOpenRouter { - if cfg.OpenRouterAppURL != "" { - httpReq.Header.Set("HTTP-Referer", cfg.OpenRouterAppURL) - } - if cfg.OpenRouterAppName != "" { - httpReq.Header.Set("X-Title", cfg.OpenRouterAppName) - } + addOpenRouterHeaders(httpReq, cfg) } client := &http.Client{ @@ -234,7 +246,7 @@ func handleStreamingMessages(c *fiber.Ctx, openaiReq *models.OpenAIRequest, cfg writeSSEError(w, fmt.Sprintf("request failed: %v", err)) return } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() if cfg.Debug { fmt.Printf("[DEBUG] StreamWriter: Got response with status %d\n", resp.StatusCode) @@ -274,8 +286,20 @@ type ToolCallState struct { Started bool // Flag if content_block_start was sent } -// streamOpenAIToClaude converts OpenAI SSE stream to Claude SSE format -// This implementation matches the Python version line-by-line +// streamOpenAIToClaude converts OpenAI streaming responses to Claude's SSE event format. +// +// It processes the OpenAI SSE stream chunk-by-chunk, generating the proper sequence of +// Claude events: message_start, content_block_start, content_block_delta, content_block_stop, +// message_delta, and message_stop. +// +// Handles: +// - Thinking blocks from reasoning models (OpenRouter's reasoning_details, OpenAI's reasoning_content) +// - Text content deltas +// - Tool call deltas (accumulates JSON arguments across chunks) +// - Token usage tracking and throughput calculation for simple log mode +// +// The function maintains state to track content block indices, tool call accumulation, +// and ensures proper event ordering for Claude Code compatibility. func streamOpenAIToClaude(w *bufio.Writer, reader io.Reader, providerModel string, cfg *config.Config, startTime time.Time) { if cfg.Debug { fmt.Printf("[DEBUG] streamOpenAIToClaude: Starting conversion\n") @@ -334,7 +358,7 @@ func streamOpenAIToClaude(w *bufio.Writer, reader io.Reader, providerModel strin "type": "ping", }) - w.Flush() + _ = w.Flush() // Process streaming chunks (matches Python lines 111-210) for scanner.Scan() { @@ -480,7 +504,7 @@ func streamOpenAIToClaude(w *bufio.Writer, reader io.Reader, providerModel strin }, }) thinkingBlockStarted = true - w.Flush() + _ = w.Flush() } // Send thinking block delta @@ -493,7 +517,7 @@ func streamOpenAIToClaude(w *bufio.Writer, reader io.Reader, providerModel strin }, }) thinkingBlockHasContent = true - w.Flush() + _ = w.Flush() } } } @@ -513,7 +537,7 @@ func streamOpenAIToClaude(w *bufio.Writer, reader io.Reader, providerModel strin }, }) thinkingBlockStarted = true - w.Flush() + _ = w.Flush() } // Send thinking block delta @@ -526,7 +550,7 @@ func streamOpenAIToClaude(w *bufio.Writer, reader io.Reader, providerModel strin }, }) thinkingBlockHasContent = true - w.Flush() + _ = w.Flush() } // Handle text delta (matches Python lines 146-147) @@ -542,7 +566,7 @@ func streamOpenAIToClaude(w *bufio.Writer, reader io.Reader, providerModel strin }, }) textBlockStarted = true - w.Flush() + _ = w.Flush() } writeSSEEvent(w, "content_block_delta", map[string]interface{}{ @@ -553,7 +577,7 @@ func streamOpenAIToClaude(w *bufio.Writer, reader io.Reader, providerModel strin "text": content, }, }) - w.Flush() + _ = w.Flush() } // Handle tool call deltas (matches Python lines 149-198) @@ -620,7 +644,7 @@ func streamOpenAIToClaude(w *bufio.Writer, reader io.Reader, providerModel strin "input": map[string]interface{}{}, }, }) - w.Flush() + _ = w.Flush() } // Handle function arguments (matches Python lines 186-198) @@ -646,7 +670,7 @@ func streamOpenAIToClaude(w *bufio.Writer, reader io.Reader, providerModel strin "partial_json": toolCall.ArgsBuffer, }, }) - w.Flush() + _ = w.Flush() toolCall.JSONSent = true } } @@ -661,13 +685,14 @@ func streamOpenAIToClaude(w *bufio.Writer, reader io.Reader, providerModel strin // Handle finish reason (matches Python lines 200-210) // NOTE: Don't break here - with stream_options.include_usage, OpenAI sends usage in a chunk AFTER finish_reason if finishReason, ok := choice["finish_reason"].(string); ok && finishReason != "" { - if finishReason == "length" { + switch finishReason { + case "length": finalStopReason = "max_tokens" - } else if finishReason == "tool_calls" || finishReason == "function_call" { + case "tool_calls", "function_call": finalStopReason = "tool_use" - } else if finishReason == "stop" { + case "stop": finalStopReason = "end_turn" - } else { + default: finalStopReason = "end_turn" } // Continue processing to capture usage chunk (don't break) @@ -682,7 +707,7 @@ func streamOpenAIToClaude(w *bufio.Writer, reader io.Reader, providerModel strin "type": "content_block_stop", "index": textBlockIndex, }) - w.Flush() + _ = w.Flush() } // Send content_block_stop for each tool call (matches Python lines 228-230) @@ -693,7 +718,7 @@ func streamOpenAIToClaude(w *bufio.Writer, reader io.Reader, providerModel strin "type": "content_block_stop", "index": toolData.ClaudeIndex, }) - w.Flush() + _ = w.Flush() } } @@ -703,7 +728,7 @@ func streamOpenAIToClaude(w *bufio.Writer, reader io.Reader, providerModel strin "type": "content_block_stop", "index": thinkingBlockIndex, }) - w.Flush() + _ = w.Flush() } // Debug: Check if usage data was received @@ -730,13 +755,13 @@ func streamOpenAIToClaude(w *bufio.Writer, reader io.Reader, providerModel strin }, "usage": usageData, }) - w.Flush() + _ = w.Flush() // Send message_stop (matches Python line 234) writeSSEEvent(w, "message_stop", map[string]interface{}{ "type": "message_stop", }) - w.Flush() + _ = w.Flush() // Simple log: one-line summary if cfg.SimpleLog { @@ -787,8 +812,8 @@ func streamOpenAIToClaude(w *bufio.Writer, reader io.Reader, providerModel strin // writeSSEEvent writes a Server-Sent Event func writeSSEEvent(w *bufio.Writer, event string, data interface{}) { dataJSON, _ := json.Marshal(data) - fmt.Fprintf(w, "event: %s\n", event) - fmt.Fprintf(w, "data: %s\n\n", string(dataJSON)) + _, _ = fmt.Fprintf(w, "event: %s\n", event) + _, _ = fmt.Fprintf(w, "data: %s\n\n", string(dataJSON)) } // writeSSEError writes an error event @@ -800,7 +825,7 @@ func writeSSEError(w *bufio.Writer, message string) { "message": message, }, }) - w.Flush() + _ = w.Flush() } // callOpenAI makes an HTTP request to the OpenAI API @@ -830,12 +855,7 @@ func callOpenAI(req *models.OpenAIRequest, cfg *config.Config) (*models.OpenAIRe // OpenRouter-specific headers for better rate limits if cfg.DetectProvider() == config.ProviderOpenRouter { - if cfg.OpenRouterAppURL != "" { - httpReq.Header.Set("HTTP-Referer", cfg.OpenRouterAppURL) - } - if cfg.OpenRouterAppName != "" { - httpReq.Header.Set("X-Title", cfg.OpenRouterAppName) - } + addOpenRouterHeaders(httpReq, cfg) } // Create HTTP client with timeout @@ -848,7 +868,7 @@ func callOpenAI(req *models.OpenAIRequest, cfg *config.Config) (*models.OpenAIRe if err != nil { return nil, fmt.Errorf("request failed: %w", err) } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() // Read response body respBody, err := io.ReadAll(resp.Body) diff --git a/internal/server/server.go b/internal/server/server.go index 90c3b85..1f77bc0 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -1,3 +1,10 @@ +// Package server implements the HTTP proxy server that translates between +// Claude API format and OpenAI-compatible providers (OpenRouter, OpenAI Direct, Ollama). +// +// The server receives Claude API requests on /v1/messages, converts them to OpenAI format, +// forwards them to the configured provider, and converts responses back to Claude format. +// It handles both streaming (SSE) and non-streaming responses, including tool calls and +// thinking blocks from reasoning models. package server import ( @@ -15,12 +22,17 @@ import ( "github.com/gofiber/fiber/v2/middleware/recover" ) +const ( + // ProxyVersion is the current version of the Claude Code Proxy + ProxyVersion = "1.0.0" +) + // Start initializes and starts the HTTP server func Start(cfg *config.Config) error { app := fiber.New(fiber.Config{ DisableStartupMessage: true, ServerHeader: "Claude-Code-Proxy", - AppName: "Claude Code Proxy v1.0.0", + AppName: "Claude Code Proxy v" + ProxyVersion, }) // Middleware @@ -42,7 +54,7 @@ func Start(cfg *config.Config) error { app.Get("/health", func(c *fiber.Ctx) error { return c.JSON(fiber.Map{ "status": "ok", - "version": "1.0.0", + "version": ProxyVersion, }) }) @@ -50,7 +62,7 @@ func Start(cfg *config.Config) error { app.Get("/", func(c *fiber.Ctx) error { return c.JSON(fiber.Map{ "message": "Claude Code Proxy", - "version": "1.0.0", + "version": ProxyVersion, "status": "running", "config": fiber.Map{ "openai_base_url": cfg.OpenAIBaseURL,