diff --git a/.factory/settings.json b/.factory/settings.json
index f1f2137288a11a3a187c63073cd81137a72f3bce..1a3dd4999db617c7fd244e31592c16f80ce669c8 100644
--- a/.factory/settings.json
+++ b/.factory/settings.json
@@ -51,9 +51,9 @@
},
{
"model": "gemini-3-pro-preview",
- "displayName": "gemini-3-pro-preview",
- "baseUrl": "https://shimen-cliproxyapi.hf.space/v1",
- "apiKey": "shin",
+ "displayName": "Gemini 3 Pro [Google]",
+ "baseUrl": "https://generativelanguage.googleapis.com/v1beta/",
+ "apiKey": "AIzaSyCc9zZOS82GvtbQQFItyHNbQwc7zzxFkN0",
"provider": "generic-chat-completion-api"
},
{
diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml
new file mode 100644
index 0000000000000000000000000000000000000000..6c09a2e6d85d3721844bdb9b06c90ad5836f90f6
--- /dev/null
+++ b/.github/workflows/lint.yml
@@ -0,0 +1,25 @@
+name: Lint
+
+on:
+ push:
+ branches: [main, develop]
+ pull_request:
+ branches: [main, develop]
+
+jobs:
+ golangci:
+ name: lint
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: actions/setup-go@v5
+ with:
+ go-version: '1.24'
+
+ - name: golangci-lint
+ uses: golangci/golangci-lint-action@v6
+ with:
+ version: latest
+ args: --timeout=5m
+ only-new-issues: true
diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml
new file mode 100644
index 0000000000000000000000000000000000000000..e0718e939fcc3c9644698e94939268a1e542c709
--- /dev/null
+++ b/.github/workflows/security.yml
@@ -0,0 +1,67 @@
+name: Security
+
+on:
+ push:
+ branches: [main, develop]
+ pull_request:
+ branches: [main, develop]
+ schedule:
+ - cron: '0 0 * * 0' # Weekly
+
+jobs:
+ gosec:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Run Gosec
+ uses: securego/gosec@master
+ with:
+ args: '-fmt sarif -out results.sarif ./...'
+
+ - name: Upload SARIF
+ uses: github/codeql-action/upload-sarif@v2
+ with:
+ sarif_file: results.sarif
+
+ govulncheck:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: actions/setup-go@v5
+ with:
+ go-version: '1.24'
+
+ - name: Install govulncheck
+ run: go install golang.org/x/vuln/cmd/govulncheck@latest
+
+ - name: Run govulncheck
+ run: govulncheck ./...
+
+ trivy:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Build image
+ run: docker build -t cliproxy:test .
+
+ - name: Run Trivy
+ uses: aquasecurity/trivy-action@master
+ with:
+ image-ref: 'cliproxy:test'
+ format: 'sarif'
+ output: 'trivy-results.sarif'
+
+ - name: Upload SARIF
+ uses: github/codeql-action/upload-sarif@v2
+ with:
+ sarif_file: trivy-results.sarif
+
+ dependency-review:
+ runs-on: ubuntu-latest
+ if: github.event_name == 'pull_request'
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/dependency-review-action@v3
diff --git a/.gitignore b/.gitignore
index 9d3989a297f89ed14304542d75c74088fd578a40..08bad67fd9087d027482281ec70de7850f74098b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,6 +1,7 @@
# Binaries
cli-proxy-api
*.exe
+main
# Configuration
config.yaml
diff --git a/.golangci.yml b/.golangci.yml
new file mode 100644
index 0000000000000000000000000000000000000000..a8059d66f892872c3a3ea08aff69e68a4a9103ca
--- /dev/null
+++ b/.golangci.yml
@@ -0,0 +1,101 @@
+run:
+ timeout: 5m
+ go: '1.24'
+
+issues:
+ exclude-dirs:
+ - management-center
+ - kiro-gateway
+ exclude-rules:
+ # Exclude init() function warnings in translator registrations
+ - path: internal/translator/.*/init\.go
+ linters:
+ - gochecknoinits
+
+ # Exclude underscore variable warnings in test files
+ - path: _test\.go
+ linters:
+ - errcheck
+
+ # Exclude long function warnings in handlers (will refactor separately)
+ - path: internal/api/handlers/
+ linters:
+ - funlen
+ - gocognit
+
+ exclude-use-default: false
+ max-issues-per-linter: 0
+ max-same-issues: 0
+
+linters:
+ enable:
+ # Default
+ - errcheck
+ - gosimple
+ - govet
+ - ineffassign
+ - staticcheck
+ - unused
+ # Additional
+ - bodyclose
+ - dogsled
+ - dupl
+ - exhaustive
+ - goconst
+ - gocritic
+ - gofmt
+ - goimports
+ - mnd
+ - goprintffuncname
+ - gosec
+ - misspell
+ - nakedret
+ - noctx
+ - nolintlint
+ - prealloc
+ - revive
+ - stylecheck
+ - unconvert
+ - unparam
+ - whitespace
+
+linters-settings:
+ gocritic:
+ enabled-tags:
+ - performance
+ - style
+ - experimental
+ disabled-checks:
+ - wrapperFunc
+ - dupImport
+
+ revive:
+ rules:
+ - name: unexported-return
+ disabled: false
+ - name: exported
+ disabled: false
+ - name: package-comments
+ disabled: true
+
+ mnd:
+ checks:
+ - argument
+ - case
+ - condition
+ - operation
+ - return
+ ignored-numbers:
+ - '0'
+ - '1'
+ - '2'
+ - '10'
+ - '60'
+ - '100'
+
+ dupl:
+ threshold: 100
+
+ gosec:
+ excludes:
+ - G104 # Audit errors not checked (handled by errcheck)
diff --git a/.kilocode/mcp.json b/.kilocode/mcp.json
new file mode 100644
index 0000000000000000000000000000000000000000..df50ae70dca17219195fee8014e43728e90fcc2b
--- /dev/null
+++ b/.kilocode/mcp.json
@@ -0,0 +1,21 @@
+{
+ "mcpServers": {
+ "context7": {
+ "command": "npx",
+ "args": [
+ "-y",
+ "@upstash/context7-mcp"
+ ],
+ "env": {
+ "DEFAULT_MINIMUM_TOKENS": ""
+ }
+ },
+ "sequentialthinking": {
+ "command": "npx",
+ "args": [
+ "-y",
+ "@modelcontextprotocol/server-sequential-thinking"
+ ]
+ }
+ }
+}
\ No newline at end of file
diff --git a/HF_SPACES_DEPLOYMENT.md b/HF_SPACES_DEPLOYMENT.md
new file mode 100644
index 0000000000000000000000000000000000000000..8176a68825a8087fd42e4db96e75776da70aa2b0
--- /dev/null
+++ b/HF_SPACES_DEPLOYMENT.md
@@ -0,0 +1,129 @@
+# HF Spaces Deployment - Claude Multi-API Key Feature
+
+## Overview
+This version of CLIProxyAPI includes support for multiple Claude API keys with round-robin load balancing, accessible through both the Management UI and API endpoints.
+
+## What's New
+
+### 1. Multiple Claude API Keys
+- Add multiple API keys per Claude configuration
+- Each key can have its own proxy URL
+- Automatic round-robin load balancing across all keys
+- Backward compatible with single-key configurations
+
+### 2. Updated Management UI
+The management UI (`/management.html`) now includes:
+- **AI Providers** → **Claude API Configuration** section
+- Add/remove multiple API keys with individual proxy settings
+- Visual interface for managing key entries
+
+### 3. API Endpoints
+The following endpoints support the new `api-key-entries` field:
+
+- `GET /api/config/claude-api-key` - Returns configurations with `api-key-entries`
+- `PUT /api/config/claude-api-key` - Create/update with multiple keys
+- `PATCH /api/config/claude-api-key` - Update specific fields including `api-key-entries`
+- `DELETE /api/config/claude-api-key` - Delete by API key or index
+
+## Configuration Examples
+
+### Via Management UI
+1. Access `https://your-space.hf.space/management.html`
+2. Navigate to **AI Providers** → **Claude API Configuration**
+3. Click **Add Configuration**
+4. Add multiple API keys in the **API Keys** section
+5. Save
+
+### Via Config File (config.yaml)
+
+```yaml
+claude-api-key:
+ - api-key-entries:
+ - api-key: "sk-ant-api03-key1..."
+ proxy-url: "http://proxy1:8080"
+ - api-key: "sk-ant-api03-key2..."
+ proxy-url: "http://proxy2:8080"
+ - api-key: "sk-ant-api03-key3..."
+ base-url: "https://api.anthropic.com"
+ priority: 10
+ prefix: "team-a/"
+ models:
+ - name: claude-3-5-sonnet-20241022
+ alias: claude-sonnet
+ - name: claude-3-opus-20240229
+ alias: claude-opus
+```
+
+### Via API
+
+```bash
+# Add Claude configuration with multiple keys
+curl -X PUT https://your-space.hf.space/api/config/claude-api-key \
+ -H "Content-Type: application/json" \
+ -H "Authorization: Bearer your-api-key" \
+ -d '[{
+ "api-key-entries": [
+ {"api-key": "sk-ant-api03-key1...", "proxy-url": "http://proxy1:8080"},
+ {"api-key": "sk-ant-api03-key2...", "proxy-url": "http://proxy2:8080"}
+ ],
+ "base-url": "https://api.anthropic.com",
+ "priority": 10,
+ "models": [
+ {"name": "claude-3-5-sonnet-20241022", "alias": "claude-sonnet"}
+ ]
+ }]'
+```
+
+## How It Works
+
+1. **Load Balancing**: Each API key becomes a separate Auth entry. The system uses round-robin selection to distribute requests across all available keys.
+
+2. **Per-Key Proxy**: Each API key entry can specify its own `proxy-url`. If not specified, it falls back to the key-level `proxy-url` or no proxy.
+
+3. **Backward Compatibility**: The old single `api-key` field still works. If `api-key-entries` is empty, the system uses the single `api-key`.
+
+4. **Failover**: If one API key fails (e.g., rate limited), the system automatically tries the next key in the rotation.
+
+## Files Modified
+
+### Backend (Go)
+- `internal/config/config.go` - Added `ClaudeAPIKeyEntry` type and updated `ClaudeKey`
+- `internal/watcher/synthesizer/config.go` - Updated `synthesizeClaudeKeys()` for multi-key support
+- `internal/watcher/diff/config_diff.go` - Added diff detection for `api-key-entries`
+- `internal/api/handlers/management/config_lists.go` - Updated API handlers
+- `internal/managementasset/management.html` - Updated React UI build
+
+### Frontend (React/TypeScript)
+- `management-center/src/types/provider.ts` - Added `apiKeyEntries` to types
+- `management-center/src/components/providers/ClaudeSection/ClaudeModal.tsx` - New multi-key UI
+- `management-center/src/components/providers/types.ts` - Updated form state types
+- `management-center/src/i18n/locales/*.json` - Added translation keys
+
+## Testing
+
+All tests pass for the modified components:
+```bash
+go test ./internal/config/... ./internal/watcher/... ./internal/api/handlers/management/... -v
+```
+
+## Deployment Notes
+
+1. The `management.html` file is embedded in the binary at `/home/internal/managementasset/management.html`
+2. The Dockerfile copies the entire project, so the management UI is included automatically
+3. No additional environment variables are required for the multi-key feature
+4. The feature works out of the box once deployed
+
+## Troubleshooting
+
+### Management UI Not Loading
+- Ensure the `management.html` file exists in the Docker image
+- Check that `RemoteManagement.DisableControlPanel` is not set to `true` in config
+
+### API Keys Not Rotating
+- Verify that `api-key-entries` is properly formatted in the config
+- Check logs for any synthesis errors
+- Ensure at least one API key has a non-empty value
+
+### Per-Key Proxy Not Working
+- Verify the proxy URL format (e.g., `http://proxy.example.com:8080`)
+- Check that the proxy is accessible from the HF Spaces environment
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000000000000000000000000000000000000..70c7e8fe1f6098922589f6317b2553487dbd322b
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,20 @@
+GOPATH=$(shell go env GOPATH)
+GOLANGCI_LINT=$(GOPATH)/bin/golangci-lint
+
+.PHONY: lint lint-fix lint-install lint-precommit
+
+lint-install:
+ go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest
+
+lint:
+ $(GOLANGCI_LINT) run ./...
+
+lint-fix:
+ $(GOLANGCI_LINT) run --fix ./...
+
+# Pre-commit hook
+lint-precommit:
+ @echo "#!/bin/sh" > .git/hooks/pre-commit
+ @echo '$(GOLANGCI_LINT) run --fast ./...' >> .git/hooks/pre-commit
+ @chmod +x .git/hooks/pre-commit
+ @echo "Pre-commit hook installed"
diff --git a/internal/access/config_access/provider.go b/internal/access/config_access/provider.go
index 70824524b2e9216ea0ec79f9278461f3786156dc..694c69c2362dec140e6cb7fdaa146a66dc395255 100644
--- a/internal/access/config_access/provider.go
+++ b/internal/access/config_access/provider.go
@@ -105,7 +105,7 @@ func extractBearerToken(header string) string {
if len(parts) != 2 {
return header
}
- if strings.ToLower(parts[0]) != "bearer" {
+ if !strings.EqualFold(parts[0], "bearer") {
return header
}
return strings.TrimSpace(parts[1])
diff --git a/internal/api/handlers/management/auth_files.go b/internal/api/handlers/management/auth_files.go
index 996ea1a7789c87e31763d85cd26073ea60d084e4..7fd85c1182bff280daf7d4f5fef314a421ce8b8a 100644
--- a/internal/api/handlers/management/auth_files.go
+++ b/internal/api/handlers/management/auth_files.go
@@ -1103,7 +1103,7 @@ func (h *Handler) RequestGeminiCLIToken(c *gin.Context) {
// Create token storage (mirrors internal/auth/gemini createTokenStorage)
authHTTPClient := conf.Client(ctx, token)
- req, errNewRequest := http.NewRequestWithContext(ctx, "GET", "https://www.googleapis.com/oauth2/v1/userinfo?alt=json", nil)
+ req, errNewRequest := http.NewRequestWithContext(ctx, "GET", "https://www.googleapis.com/oauth2/v1/userinfo?alt=json", http.NoBody)
if errNewRequest != nil {
log.Errorf("Could not get user info: %v", errNewRequest)
SetOAuthSessionError(state, "Could not get user info")
@@ -2079,7 +2079,7 @@ func callGeminiCLI(ctx context.Context, httpClient *http.Client, endpoint string
}
func fetchGCPProjects(ctx context.Context, httpClient *http.Client) ([]interfaces.GCPProjectProjects, error) {
- req, errRequest := http.NewRequestWithContext(ctx, http.MethodGet, "https://cloudresourcemanager.googleapis.com/v1/projects", nil)
+ req, errRequest := http.NewRequestWithContext(ctx, http.MethodGet, "https://cloudresourcemanager.googleapis.com/v1/projects", http.NoBody)
if errRequest != nil {
return nil, fmt.Errorf("could not create project list request: %w", errRequest)
}
@@ -2114,7 +2114,7 @@ func checkCloudAPIIsEnabled(ctx context.Context, httpClient *http.Client, projec
}
for _, service := range requiredServices {
checkURL := fmt.Sprintf("%s/v1/projects/%s/services/%s", serviceUsageURL, projectID, service)
- req, errRequest := http.NewRequestWithContext(ctx, http.MethodGet, checkURL, nil)
+ req, errRequest := http.NewRequestWithContext(ctx, http.MethodGet, checkURL, http.NoBody)
if errRequest != nil {
return false, fmt.Errorf("failed to create request: %w", errRequest)
}
diff --git a/internal/api/handlers/management/config_basic.go b/internal/api/handlers/management/config_basic.go
index 2d3cd1fb63278e1d1616cf0f6c43a99ccecfd0b0..9f762d481c7d28b7e909ebbd420241eb60edfe4a 100644
--- a/internal/api/handlers/management/config_basic.go
+++ b/internal/api/handlers/management/config_basic.go
@@ -49,7 +49,7 @@ func (h *Handler) GetLatestVersion(c *gin.Context) {
util.SetProxy(sdkCfg, client)
}
- req, err := http.NewRequestWithContext(c.Request.Context(), http.MethodGet, latestReleaseURL, nil)
+ req, err := http.NewRequestWithContext(c.Request.Context(), http.MethodGet, latestReleaseURL, http.NoBody)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "request_create_failed", "message": err.Error()})
return
diff --git a/internal/api/handlers/management/handler.go b/internal/api/handlers/management/handler.go
index 613c9841d0e70134a1d6125409a1deea95edadec..547c7bcdeaf56a48d1ceacb9fbb0cee5e7e79c39 100644
--- a/internal/api/handlers/management/handler.go
+++ b/internal/api/handlers/management/handler.go
@@ -205,7 +205,7 @@ func (h *Handler) Middleware() gin.HandlerFunc {
var provided string
if ah := c.GetHeader("Authorization"); ah != "" {
parts := strings.SplitN(ah, " ", 2)
- if len(parts) == 2 && strings.ToLower(parts[0]) == "bearer" {
+ if len(parts) == 2 && strings.EqualFold(parts[0], "bearer") {
provided = parts[1]
} else {
provided = ah
diff --git a/internal/api/middleware/correlation.go b/internal/api/middleware/correlation.go
deleted file mode 100644
index a0da1c192490074bb151de634d25598719dccfb0..0000000000000000000000000000000000000000
--- a/internal/api/middleware/correlation.go
+++ /dev/null
@@ -1,54 +0,0 @@
-// Package middleware provides HTTP middleware components for the CLI Proxy API server.
-// This file contains correlation ID middleware for request tracing.
-package middleware
-
-import (
- "github.com/gin-gonic/gin"
- "github.com/google/uuid"
-)
-
-const (
- // CorrelationIDHeader is the HTTP header name for correlation IDs
- CorrelationIDHeader = "X-Correlation-ID"
- // CorrelationIDContextKey is the context key for correlation IDs
- CorrelationIDContextKey = "correlation_id"
-)
-
-// CorrelationIDMiddleware creates a Gin middleware that ensures every request
-// has a correlation ID for distributed tracing. It checks for an existing ID
-// in the request headers and generates a new one if not present.
-func CorrelationIDMiddleware() gin.HandlerFunc {
- return func(c *gin.Context) {
- // Check for existing correlation ID in header
- correlationID := c.GetHeader(CorrelationIDHeader)
-
- // Generate new ID if not present
- if correlationID == "" {
- correlationID = generateCorrelationID()
- }
-
- // Store in context
- c.Set(CorrelationIDContextKey, correlationID)
-
- // Add to response headers
- c.Header(CorrelationIDHeader, correlationID)
-
- c.Next()
- }
-}
-
-// GetCorrelationID retrieves the correlation ID from the Gin context.
-// Returns empty string if no correlation ID is found.
-func GetCorrelationID(c *gin.Context) string {
- if id, exists := c.Get(CorrelationIDContextKey); exists {
- if str, ok := id.(string); ok {
- return str
- }
- }
- return ""
-}
-
-// generateCorrelationID generates a new unique correlation ID.
-func generateCorrelationID() string {
- return uuid.New().String()
-}
diff --git a/internal/api/middleware/error_handler.go b/internal/api/middleware/error_handler.go
index 397d50eff8421a08ee347f1222a644d087b9ae71..77e38484a5693252d4ff3b3024ec484c4526006d 100644
--- a/internal/api/middleware/error_handler.go
+++ b/internal/api/middleware/error_handler.go
@@ -1,108 +1,80 @@
-// Package middleware provides HTTP middleware components for the CLI Proxy API server.
-// This file contains centralized error handling middleware for consistent API responses.
package middleware
import (
+ "errors"
"net/http"
"github.com/gin-gonic/gin"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/domain/errors"
-)
-
-// ErrorResponse represents a standardized error response
-type ErrorResponse struct {
- Success bool `json:"success"`
- Error *APIError `json:"error,omitempty"`
- RequestID string `json:"request_id,omitempty"`
-}
+ "github.com/sirupsen/logrus"
-// APIError represents error details
-type APIError struct {
- Code string `json:"code"`
- Message string `json:"message"`
- Field string `json:"field,omitempty"`
-}
+ "github.com/router-for-me/CLIProxyAPI/v6/internal/application/dto"
+ domainerrors "github.com/router-for-me/CLIProxyAPI/v6/internal/domain/errors"
+)
-// ErrorHandlerMiddleware creates a Gin middleware that provides centralized
-// error handling. It catches errors from the context and formats them into
-// standardized error responses.
-func ErrorHandlerMiddleware() gin.HandlerFunc {
+// ErrorHandler returns a middleware that handles domain errors
+func ErrorHandler(logger *logrus.Logger) gin.HandlerFunc {
return func(c *gin.Context) {
c.Next()
// Check if there are any errors
- if len(c.Errors) > 0 {
- err := c.Errors.Last()
- handleError(c, err)
+ if len(c.Errors) == 0 {
+ return
}
- }
-}
-// handleError converts various error types to standardized HTTP responses
-func handleError(c *gin.Context, err *gin.Error) {
- requestID := GetCorrelationID(c)
-
- // Check if it's a domain error
- if domainErr, ok := err.Err.(*errors.DomainError); ok {
- statusCode := domainErr.HTTPStatusCode()
- apiErr := &APIError{
- Code: string(domainErr.Code),
- Message: domainErr.Message,
- }
- // Extract field from details if available
- if domainErr.Details != nil {
- if field, ok := domainErr.Details["field"].(string); ok {
- apiErr.Field = field
- }
+ // Get the last error
+ err := c.Errors.Last().Err
+ requestID := c.GetString("request_id")
+
+ // Handle domain errors
+ if domainErr, ok := err.(*domainerrors.DomainError); ok {
+ handleDomainError(c, domainErr, requestID, logger)
+ return
}
- response := ErrorResponse{
- Success: false,
- RequestID: requestID,
- Error: apiErr,
+
+ // Handle wrapped domain errors
+ var domainErr *domainerrors.DomainError
+ if errors.As(err, &domainErr) {
+ handleDomainError(c, domainErr, requestID, logger)
+ return
}
- c.JSON(statusCode, response)
- return
- }
- // Handle common HTTP status errors
- switch err.Type {
- case gin.ErrorTypeBind:
- c.JSON(http.StatusBadRequest, ErrorResponse{
- Success: false,
- RequestID: requestID,
- Error: &APIError{
- Code: "INVALID_INPUT",
- Message: "Invalid request format: " + err.Err.Error(),
- },
- })
- case gin.ErrorTypeRender:
- c.JSON(http.StatusInternalServerError, ErrorResponse{
- Success: false,
- RequestID: requestID,
- Error: &APIError{
- Code: "RENDER_ERROR",
- Message: "Failed to render response",
- },
- })
- default:
- c.JSON(http.StatusInternalServerError, ErrorResponse{
+ // Unknown error - log full details but return generic message
+ logger.WithError(err).
+ WithField("request_id", requestID).
+ WithField("path", c.Request.URL.Path).
+ Error("Unhandled error")
+
+ c.JSON(http.StatusInternalServerError, dto.InternalErrorResponse{
Success: false,
+ Error: "An unexpected error occurred",
RequestID: requestID,
- Error: &APIError{
- Code: "INTERNAL_ERROR",
- Message: "An internal error occurred",
- },
})
}
}
-// AbortWithDomainError aborts the request with a domain error
-func AbortWithDomainError(c *gin.Context, err *errors.DomainError) {
- c.Error(err)
- c.Abort()
-}
+func handleDomainError(c *gin.Context, err *domainerrors.DomainError, requestID string, logger *logrus.Logger) {
+ // Log with appropriate level based on status code
+ entry := logger.WithError(err).
+ WithField("request_id", requestID).
+ WithField("error_code", err.Code).
+ WithField("path", c.Request.URL.Path)
+
+ statusCode := err.HTTPStatusCode()
+
+ if statusCode >= 500 {
+ entry.Error("Server error")
+ } else if statusCode >= 400 {
+ entry.Warn("Client error")
+ }
-// RespondWithError adds an error to the context without aborting
-func RespondWithError(c *gin.Context, err error) {
- c.Error(err)
+ // Return structured error response
+ c.JSON(statusCode, dto.ErrorResponse{
+ Success: false,
+ Error: dto.ErrorInfo{
+ Code: string(err.Code),
+ Message: err.Message,
+ Details: err.Details,
+ },
+ RequestID: requestID,
+ })
}
diff --git a/internal/api/middleware/middleware_test.go b/internal/api/middleware/middleware_test.go
index b732e4f31ee87940ebfa3869f500362d3287caa5..667d49b140e5b32ca0a6bd8030ae8614f5ea4cae 100644
--- a/internal/api/middleware/middleware_test.go
+++ b/internal/api/middleware/middleware_test.go
@@ -9,6 +9,7 @@ import (
"testing"
"github.com/gin-gonic/gin"
+ "github.com/sirupsen/logrus"
"github.com/stretchr/testify/assert"
)
@@ -17,42 +18,42 @@ func setupTestRouter() *gin.Engine {
return gin.New()
}
-func TestCorrelationIDMiddleware(t *testing.T) {
+func TestRequestIDMiddleware(t *testing.T) {
router := setupTestRouter()
- router.Use(CorrelationIDMiddleware())
+ router.Use(RequestID())
router.GET("/test", func(c *gin.Context) {
- id := GetCorrelationID(c)
- c.JSON(200, gin.H{"correlation_id": id})
+ id := c.GetString("request_id")
+ c.JSON(200, gin.H{"request_id": id})
})
- t.Run("generates correlation ID when not provided", func(t *testing.T) {
+ t.Run("generates request ID when not provided", func(t *testing.T) {
w := httptest.NewRecorder()
- req, _ := http.NewRequest("GET", "/test", nil)
+ req, _ := http.NewRequest("GET", "/test", http.NoBody)
router.ServeHTTP(w, req)
assert.Equal(t, 200, w.Code)
- // Check that response contains a correlation ID
- assert.Contains(t, w.Body.String(), "correlation_id")
- // Check that response header contains the correlation ID
- assert.NotEmpty(t, w.Header().Get(CorrelationIDHeader))
+ // Check that response contains a request ID
+ assert.Contains(t, w.Body.String(), "request_id")
+ // Check that response header contains the request ID
+ assert.NotEmpty(t, w.Header().Get("X-Request-ID"))
})
- t.Run("uses existing correlation ID from header", func(t *testing.T) {
+ t.Run("uses existing request ID from header", func(t *testing.T) {
w := httptest.NewRecorder()
- req, _ := http.NewRequest("GET", "/test", nil)
- req.Header.Set(CorrelationIDHeader, "test-correlation-id-123")
+ req, _ := http.NewRequest("GET", "/test", http.NoBody)
+ req.Header.Set("X-Request-ID", "test-request-id-123")
router.ServeHTTP(w, req)
assert.Equal(t, 200, w.Code)
- assert.Contains(t, w.Body.String(), "test-correlation-id-123")
- assert.Equal(t, "test-correlation-id-123", w.Header().Get(CorrelationIDHeader))
+ assert.Contains(t, w.Body.String(), "test-request-id-123")
+ assert.Equal(t, "test-request-id-123", w.Header().Get("X-Request-ID"))
})
}
-func TestErrorHandlerMiddleware(t *testing.T) {
+func TestErrorHandler(t *testing.T) {
router := setupTestRouter()
- router.Use(CorrelationIDMiddleware())
- router.Use(ErrorHandlerMiddleware())
+ router.Use(RequestID())
+ router.Use(ErrorHandler(logrus.New()))
router.GET("/error", func(c *gin.Context) {
c.Error(errors.New("test error"))
@@ -65,16 +66,16 @@ func TestErrorHandlerMiddleware(t *testing.T) {
t.Run("handles errors gracefully", func(t *testing.T) {
w := httptest.NewRecorder()
- req, _ := http.NewRequest("GET", "/error", nil)
+ req, _ := http.NewRequest("GET", "/error", http.NoBody)
router.ServeHTTP(w, req)
assert.Equal(t, 500, w.Code)
- assert.Contains(t, w.Body.String(), "INTERNAL_ERROR")
+ assert.Contains(t, w.Body.String(), "An unexpected error occurred")
})
t.Run("passes through successful requests", func(t *testing.T) {
w := httptest.NewRecorder()
- req, _ := http.NewRequest("GET", "/success", nil)
+ req, _ := http.NewRequest("GET", "/success", http.NoBody)
router.ServeHTTP(w, req)
assert.Equal(t, 200, w.Code)
@@ -85,8 +86,8 @@ func TestErrorHandlerMiddleware(t *testing.T) {
func TestRecoveryMiddleware(t *testing.T) {
router := setupTestRouter()
router.Use(RecoveryMiddleware(nil))
- router.Use(CorrelationIDMiddleware())
- router.Use(ErrorHandlerMiddleware())
+ router.Use(RequestID())
+ router.Use(ErrorHandler(logrus.New()))
router.GET("/panic", func(c *gin.Context) {
panic("test panic")
@@ -98,16 +99,16 @@ func TestRecoveryMiddleware(t *testing.T) {
t.Run("recovers from panic", func(t *testing.T) {
w := httptest.NewRecorder()
- req, _ := http.NewRequest("GET", "/panic", nil)
+ req, _ := http.NewRequest("GET", "/panic", http.NoBody)
router.ServeHTTP(w, req)
assert.Equal(t, 500, w.Code)
- assert.Contains(t, w.Body.String(), "INTERNAL_ERROR")
+ assert.Contains(t, w.Body.String(), "An internal server error occurred")
})
t.Run("normal requests work after panic recovery", func(t *testing.T) {
w := httptest.NewRecorder()
- req, _ := http.NewRequest("GET", "/normal", nil)
+ req, _ := http.NewRequest("GET", "/normal", http.NoBody)
router.ServeHTTP(w, req)
assert.Equal(t, 200, w.Code)
@@ -117,8 +118,8 @@ func TestRecoveryMiddleware(t *testing.T) {
func TestSafeHandler(t *testing.T) {
router := setupTestRouter()
- router.Use(CorrelationIDMiddleware())
- router.Use(ErrorHandlerMiddleware())
+ router.Use(RequestID())
+ router.Use(ErrorHandler(logrus.New()))
router.GET("/safe-panic", SafeHandler(func(c *gin.Context) {
panic("safe handler panic")
@@ -126,10 +127,10 @@ func TestSafeHandler(t *testing.T) {
t.Run("safe handler recovers from panic", func(t *testing.T) {
w := httptest.NewRecorder()
- req, _ := http.NewRequest("GET", "/safe-panic", nil)
+ req, _ := http.NewRequest("GET", "/safe-panic", http.NoBody)
router.ServeHTTP(w, req)
assert.Equal(t, 500, w.Code)
- assert.Contains(t, w.Body.String(), "INTERNAL_ERROR")
+ assert.Contains(t, w.Body.String(), "An internal server error occurred")
})
}
diff --git a/internal/api/middleware/rate_limit.go b/internal/api/middleware/rate_limit.go
deleted file mode 100644
index 4c5ee1cd42a7d336f2911b4225711d3cee25665c..0000000000000000000000000000000000000000
--- a/internal/api/middleware/rate_limit.go
+++ /dev/null
@@ -1,148 +0,0 @@
-// Package middleware provides HTTP middleware components for the CLI Proxy API server.
-// This file contains the rate limiting middleware that integrates with the domain
-// rate limiting service to enforce request limits and block abusive clients.
-package middleware
-
-import (
- "net/http"
- "time"
-
- "github.com/gin-gonic/gin"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/domain/ports"
-)
-
-// RateLimitMiddleware creates a Gin middleware that enforces rate limiting
-// using the provided RateLimitService. It checks if the client IP is blocked
-// and records failed authentication attempts.
-type RateLimitMiddleware struct {
- service ports.RateLimitService
-}
-
-// NewRateLimitMiddleware creates a new rate limiting middleware instance.
-func NewRateLimitMiddleware(service ports.RateLimitService) *RateLimitMiddleware {
- return &RateLimitMiddleware{
- service: service,
- }
-}
-
-// Middleware returns the Gin middleware function that enforces rate limiting.
-// It should be used for management endpoints that require authentication.
-func (m *RateLimitMiddleware) Middleware() gin.HandlerFunc {
- return func(c *gin.Context) {
- if m.service == nil {
- c.Next()
- return
- }
-
- clientIP := c.ClientIP()
- ctx := c.Request.Context()
-
- // Check if client is blocked
- blocked, blockedUntil, err := m.service.IsBlocked(ctx, clientIP)
- if err != nil {
- c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{
- "error": "rate limit check failed",
- })
- return
- }
- if blocked {
- remaining := time.Until(blockedUntil)
- if remaining < 0 {
- remaining = 0
- }
- c.AbortWithStatusJSON(http.StatusForbidden, gin.H{
- "error": "IP banned due to too many failed attempts",
- "retry_after": remaining.String(),
- })
- return
- }
-
- // Check if request is allowed
- allowed, err := m.service.Allow(ctx, clientIP)
- if err != nil {
- c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{
- "error": "rate limit check failed",
- })
- return
- }
- if !allowed {
- c.AbortWithStatusJSON(http.StatusTooManyRequests, gin.H{
- "error": "rate limit exceeded",
- })
- return
- }
-
- c.Next()
- }
-}
-
-// AuthMiddleware wraps the rate limiting middleware with authentication logic.
-// It records failed attempts when authentication fails.
-type AuthMiddleware struct {
- rateLimitService ports.RateLimitService
- getSecretHash func() string
- getEnvSecret func() string
- allowRemote func() bool
-}
-
-// NewAuthMiddleware creates a new authentication middleware with rate limiting.
-func NewAuthMiddleware(
- service ports.RateLimitService,
- getSecretHash func() string,
- getEnvSecret func() string,
- allowRemote func() bool,
-) *AuthMiddleware {
- return &AuthMiddleware{
- rateLimitService: service,
- getSecretHash: getSecretHash,
- getEnvSecret: getEnvSecret,
- allowRemote: allowRemote,
- }
-}
-
-// OnAuthFailure should be called when authentication fails to record the attempt.
-func (m *AuthMiddleware) OnAuthFailure(c *gin.Context) {
- if m.rateLimitService == nil {
- return
- }
-
- clientIP := c.ClientIP()
- ctx := c.Request.Context()
-
- m.rateLimitService.RecordAttempt(ctx, clientIP, false)
-}
-
-// OnAuthSuccess should be called when authentication succeeds to reset attempts.
-func (m *AuthMiddleware) OnAuthSuccess(c *gin.Context) {
- if m.rateLimitService == nil {
- return
- }
-
- clientIP := c.ClientIP()
- ctx := c.Request.Context()
-
- m.rateLimitService.RecordAttempt(ctx, clientIP, true)
-}
-
-// GetRetryAfter returns the duration until the client can retry after being blocked.
-func (m *AuthMiddleware) GetRetryAfter(c *gin.Context) time.Duration {
- if m.rateLimitService == nil {
- return 0
- }
-
- clientIP := c.ClientIP()
- ctx := c.Request.Context()
-
- blocked, blockedUntil, err := m.rateLimitService.IsBlocked(ctx, clientIP)
- if err != nil {
- return 0
- }
- if blocked {
- remaining := time.Until(blockedUntil)
- if remaining > 0 {
- return remaining
- }
- }
-
- return 0
-}
diff --git a/internal/api/middleware/recovery.go b/internal/api/middleware/recovery.go
index a51a043b454e18ac40ba6d1e0e2816ebd4f530c2..16f0457249ee25dc4870f7fc925a6b2e91f48e13 100644
--- a/internal/api/middleware/recovery.go
+++ b/internal/api/middleware/recovery.go
@@ -9,6 +9,8 @@ import (
"github.com/gin-gonic/gin"
"github.com/sirupsen/logrus"
+
+ "github.com/router-for-me/CLIProxyAPI/v6/internal/application/dto"
)
// RecoveryMiddleware creates a Gin middleware that recovers from panics
@@ -18,6 +20,7 @@ func RecoveryMiddleware(logger *logrus.Logger) gin.HandlerFunc {
return func(c *gin.Context) {
defer func() {
if r := recover(); r != nil {
+ requestID := c.GetString("request_id")
// Log the panic with stack trace if logger is available
if logger != nil {
logger.WithFields(logrus.Fields{
@@ -26,19 +29,15 @@ func RecoveryMiddleware(logger *logrus.Logger) gin.HandlerFunc {
"path": c.Request.URL.Path,
"method": c.Request.Method,
"client_ip": c.ClientIP(),
- "request_id": GetCorrelationID(c),
+ "request_id": requestID,
}).Error("Panic recovered in HTTP handler")
}
// Return graceful error response
- requestID := GetCorrelationID(c)
- c.AbortWithStatusJSON(http.StatusInternalServerError, ErrorResponse{
+ c.AbortWithStatusJSON(http.StatusInternalServerError, dto.InternalErrorResponse{
Success: false,
RequestID: requestID,
- Error: &APIError{
- Code: "INTERNAL_ERROR",
- Message: "An internal server error occurred",
- },
+ Error: "An internal server error occurred",
})
}
}()
@@ -53,22 +52,19 @@ func SafeHandler(handler gin.HandlerFunc) gin.HandlerFunc {
return func(c *gin.Context) {
defer func() {
if r := recover(); r != nil {
+ requestID := c.GetString("request_id")
// Log the panic
logrus.WithFields(logrus.Fields{
"panic": fmt.Sprintf("%v", r),
"path": c.Request.URL.Path,
"method": c.Request.Method,
- "request_id": GetCorrelationID(c),
+ "request_id": requestID,
}).Error("Panic recovered in handler")
- requestID := GetCorrelationID(c)
- c.AbortWithStatusJSON(http.StatusInternalServerError, ErrorResponse{
+ c.AbortWithStatusJSON(http.StatusInternalServerError, dto.InternalErrorResponse{
Success: false,
RequestID: requestID,
- Error: &APIError{
- Code: "INTERNAL_ERROR",
- Message: "An internal server error occurred",
- },
+ Error: "An internal server error occurred",
})
}
}()
diff --git a/internal/api/middleware/request_id.go b/internal/api/middleware/request_id.go
new file mode 100644
index 0000000000000000000000000000000000000000..447f63f335e899a122913495534fe2693d6e89d3
--- /dev/null
+++ b/internal/api/middleware/request_id.go
@@ -0,0 +1,18 @@
+package middleware
+
+import (
+ "github.com/gin-gonic/gin"
+ "github.com/google/uuid"
+)
+
+func RequestID() gin.HandlerFunc {
+ return func(c *gin.Context) {
+ requestID := c.GetHeader("X-Request-ID")
+ if requestID == "" {
+ requestID = uuid.New().String()
+ }
+ c.Set("request_id", requestID)
+ c.Header("X-Request-ID", requestID)
+ c.Next()
+ }
+}
diff --git a/internal/api/middleware/security.go b/internal/api/middleware/security.go
new file mode 100644
index 0000000000000000000000000000000000000000..eb4781653bf6b92f306fffd85c4a12289a4015f6
--- /dev/null
+++ b/internal/api/middleware/security.go
@@ -0,0 +1,56 @@
+package middleware
+
+import (
+ "strings"
+
+ "github.com/gin-gonic/gin"
+)
+
+func SecurityHeaders() gin.HandlerFunc {
+ return func(c *gin.Context) {
+ // Prevent clickjacking
+ c.Header("X-Frame-Options", "DENY")
+
+ // Prevent MIME type sniffing
+ c.Header("X-Content-Type-Options", "nosniff")
+
+ // XSS Protection
+ c.Header("X-XSS-Protection", "1; mode=block")
+
+ // Referrer Policy
+ c.Header("Referrer-Policy", "strict-origin-when-cross-origin")
+
+ // Permissions Policy
+ c.Header("Permissions-Policy", "geolocation=(), microphone=(), camera=()")
+
+ // HSTS (HTTPS only)
+ c.Header("Strict-Transport-Security", "max-age=31536000; includeSubDomains; preload")
+
+ // CSP for API (restrictive)
+ if !strings.HasPrefix(c.Request.URL.Path, "/management") {
+ c.Header("Content-Security-Policy", "default-src 'none'")
+ }
+
+ c.Next()
+ }
+}
+
+// CSP for Management UI
+func ManagementCSP() gin.HandlerFunc {
+ return func(c *gin.Context) {
+ csp := strings.Join([]string{
+ "default-src 'self'",
+ "script-src 'self' 'unsafe-inline' 'unsafe-eval'",
+ "style-src 'self' 'unsafe-inline'",
+ "img-src 'self' data: https:",
+ "font-src 'self'",
+ "connect-src 'self'",
+ "frame-ancestors 'none'",
+ "base-uri 'self'",
+ "form-action 'self'",
+ }, "; ")
+
+ c.Header("Content-Security-Policy", csp)
+ c.Next()
+ }
+}
diff --git a/internal/api/modules/amp/amp.go b/internal/api/modules/amp/amp.go
index b5626ce9c082b0cacf946047e9933ac371088a1e..087608aac32cf04c2a5a8ff2dace365e8c2fa211 100644
--- a/internal/api/modules/amp/amp.go
+++ b/internal/api/modules/amp/amp.go
@@ -246,7 +246,6 @@ func (m *AmpModule) OnConfigUpdated(cfg *config.Config) error {
}
}
}
-
}
// Store current config for next comparison
diff --git a/internal/api/modules/amp/amp_test.go b/internal/api/modules/amp/amp_test.go
index 430c4b62a725ca74604049d697bc617ec5f3e416..b93675c0e4bc0e41c1d4f5f06a0d3995824a3288 100644
--- a/internal/api/modules/amp/amp_test.go
+++ b/internal/api/modules/amp/amp_test.go
@@ -2,6 +2,7 @@ package amp
import (
"context"
+ "net/http"
"net/http/httptest"
"os"
"path/filepath"
@@ -106,7 +107,7 @@ func TestAmpModule_Register_WithoutUpstream(t *testing.T) {
}
// But provider aliases should still be registered
- req := httptest.NewRequest("GET", "/api/provider/openai/models", nil)
+ req := httptest.NewRequest("GET", "/api/provider/openai/models", http.NoBody)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
@@ -226,7 +227,7 @@ func TestAmpModule_AuthMiddleware_Fallback(t *testing.T) {
c.String(200, "ok")
})
- req := httptest.NewRequest("GET", "/test", nil)
+ req := httptest.NewRequest("GET", "/test", http.NoBody)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
@@ -302,7 +303,7 @@ func TestAmpModule_ProviderAliasesAlwaysRegistered(t *testing.T) {
}
// Provider aliases should always be available
- req := httptest.NewRequest("GET", "/api/provider/openai/models", nil)
+ req := httptest.NewRequest("GET", "/api/provider/openai/models", http.NoBody)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
diff --git a/internal/api/modules/amp/gemini_bridge_test.go b/internal/api/modules/amp/gemini_bridge_test.go
index 347456c383e5e89197d90824e7222c66ec4c2f9b..78722ac4139e0222323388ca6cc4db8d9079c997 100644
--- a/internal/api/modules/amp/gemini_bridge_test.go
+++ b/internal/api/modules/amp/gemini_bridge_test.go
@@ -58,7 +58,7 @@ func TestCreateGeminiBridgeHandler_ActionParameterExtraction(t *testing.T) {
}
r.POST("/api/provider/google/v1beta1/*path", bridgeHandler)
- req := httptest.NewRequest(http.MethodPost, "/api/provider/google/v1beta1"+tt.path, nil)
+ req := httptest.NewRequest(http.MethodPost, "/api/provider/google/v1beta1"+tt.path, http.NoBody)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
@@ -83,7 +83,7 @@ func TestCreateGeminiBridgeHandler_InvalidPath(t *testing.T) {
r := gin.New()
r.POST("/api/provider/google/v1beta1/*path", bridgeHandler)
- req := httptest.NewRequest(http.MethodPost, "/api/provider/google/v1beta1/invalid/path", nil)
+ req := httptest.NewRequest(http.MethodPost, "/api/provider/google/v1beta1/invalid/path", http.NoBody)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
diff --git a/internal/api/modules/amp/proxy_test.go b/internal/api/modules/amp/proxy_test.go
index ff23e3986bf098b28c527034d30f97dc87e7356c..06dd3822beb173c875bd6501c61bd2e953f52d41 100644
--- a/internal/api/modules/amp/proxy_test.go
+++ b/internal/api/modules/amp/proxy_test.go
@@ -335,7 +335,7 @@ func TestReverseProxy_StripsClientCredentialsFromHeadersAndQuery(t *testing.T) {
}))
defer srv.Close()
- req, err := http.NewRequest(http.MethodGet, srv.URL+"/test?key=client-key&key=keep&auth_token=client-key&foo=bar", nil)
+ req, err := http.NewRequest(http.MethodGet, srv.URL+"/test?key=client-key&key=keep&auth_token=client-key&foo=bar", http.NoBody)
if err != nil {
t.Fatal(err)
}
diff --git a/internal/api/modules/amp/response_rewriter_test.go b/internal/api/modules/amp/response_rewriter_test.go
index c9672619ff75e494ba925fc067597a9ac28ae1c3..ef2195578fac2d17f573b8c5d3e836a8371ab3aa 100644
--- a/internal/api/modules/amp/response_rewriter_test.go
+++ b/internal/api/modules/amp/response_rewriter_test.go
@@ -106,7 +106,7 @@ func TestResponseRewriter_SplitJSONTokensAcrossChunks(t *testing.T) {
// Simulate streaming response
for _, chunk := range tt.chunks {
- rw.Write([]byte(chunk))
+ rw.WriteString(chunk)
}
rw.Flush()
@@ -176,7 +176,7 @@ func TestResponseRewriter_InvalidMalformedJSON(t *testing.T) {
rw := NewResponseRewriter(mock, tt.originalModel)
// For non-streaming, we buffer and flush
- rw.Write([]byte(tt.input))
+ rw.WriteString(tt.input)
rw.Flush()
result := mock.body.String()
@@ -256,7 +256,7 @@ func TestResponseRewriter_MixedContentTypes(t *testing.T) {
rw := NewResponseRewriter(mock, tt.originalModel)
// First write triggers streaming detection
- rw.Write([]byte(tt.input))
+ rw.WriteString(tt.input)
if rw.isStreaming != tt.isStreaming {
t.Errorf("isStreaming = %v, want %v", rw.isStreaming, tt.isStreaming)
@@ -320,7 +320,7 @@ func TestResponseRewriter_FallbackStrategy(t *testing.T) {
// Write all chunks
for _, chunk := range tt.input {
- _, err := rw.Write([]byte(chunk))
+ _, err := rw.WriteString(chunk)
if err != nil && !tt.expectError {
t.Errorf("unexpected error: %v", err)
}
@@ -403,7 +403,7 @@ func TestResponseRewriter_SSEEdgeCases(t *testing.T) {
rw := NewResponseRewriter(mock, tt.originalModel)
for _, chunk := range tt.chunks {
- rw.Write([]byte(chunk))
+ rw.WriteString(chunk)
}
result := mock.body.String()
@@ -456,7 +456,7 @@ func TestResponseRewriter_ThinkingBlockSuppression(t *testing.T) {
mock := newMockResponseWriter()
rw := NewResponseRewriter(mock, tt.originalModel)
- rw.Write([]byte(tt.input))
+ rw.WriteString(tt.input)
rw.Flush()
result := mock.body.String()
@@ -495,7 +495,7 @@ func TestResponseRewriter_ConcurrentWrites(t *testing.T) {
}
for _, chunk := range chunks {
- rw.Write([]byte(chunk))
+ rw.WriteString(chunk)
}
rw.Flush()
@@ -515,7 +515,7 @@ func TestResponseRewriter_LargeResponse(t *testing.T) {
largeContent := strings.Repeat("a", 100000)
input := `{"model": "mapped-model", "content": "` + largeContent + `"}`
- rw.Write([]byte(input))
+ rw.WriteString(input)
rw.Flush()
result := mock.body.String()
@@ -594,27 +594,27 @@ func TestNewResponseRewriter(t *testing.T) {
// TestResponseRewriter_FlushBehavior tests Flush method behavior
func TestResponseRewriter_FlushBehavior(t *testing.T) {
tests := []struct {
- name string
+ name string
isStreaming bool
- writeData string
+ writeData string
expectFlush bool
}{
{
- name: "flush non-streaming",
+ name: "flush non-streaming",
isStreaming: false,
- writeData: `{"model": "mapped"}`,
+ writeData: `{"model": "mapped"}`,
expectFlush: true,
},
{
- name: "flush streaming",
+ name: "flush streaming",
isStreaming: true,
- writeData: `data: {"model": "mapped"}`,
+ writeData: `data: {"model": "mapped"}`,
expectFlush: true,
},
{
- name: "flush empty body",
+ name: "flush empty body",
isStreaming: false,
- writeData: "",
+ writeData: "",
expectFlush: false,
},
}
@@ -628,7 +628,7 @@ func TestResponseRewriter_FlushBehavior(t *testing.T) {
rw := NewResponseRewriter(mock, "original")
if tt.writeData != "" {
- rw.Write([]byte(tt.writeData))
+ rw.WriteString(tt.writeData)
}
// Reset flushed flag
diff --git a/internal/api/modules/amp/routes_test.go b/internal/api/modules/amp/routes_test.go
index bae890aec41a1c8b3491c0bb4e17ad4411c9a3e5..518bd4c3741446152cef9ab3d3f323d5459a7815 100644
--- a/internal/api/modules/amp/routes_test.go
+++ b/internal/api/modules/amp/routes_test.go
@@ -65,7 +65,7 @@ func TestRegisterManagementRoutes(t *testing.T) {
for _, path := range managementPaths {
t.Run(path.path, func(t *testing.T) {
proxyCalled = false
- req, err := http.NewRequest(path.method, srv.URL+path.path, nil)
+ req, err := http.NewRequest(path.method, srv.URL+path.path, http.NoBody)
if err != nil {
t.Fatalf("failed to build request: %v", err)
}
@@ -120,7 +120,7 @@ func TestRegisterProviderAliases_AllProvidersRegistered(t *testing.T) {
for _, tc := range paths {
t.Run(tc.path, func(t *testing.T) {
authCalled = false
- req := httptest.NewRequest(tc.method, tc.path, nil)
+ req := httptest.NewRequest(tc.method, tc.path, http.NoBody)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
@@ -151,7 +151,7 @@ func TestRegisterProviderAliases_DynamicModelsHandler(t *testing.T) {
for _, provider := range providers {
t.Run(provider, func(t *testing.T) {
path := "/api/provider/" + provider + "/models"
- req := httptest.NewRequest(http.MethodGet, path, nil)
+ req := httptest.NewRequest(http.MethodGet, path, http.NoBody)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
@@ -185,7 +185,7 @@ func TestRegisterProviderAliases_V1Routes(t *testing.T) {
for _, tc := range v1Paths {
t.Run(tc.path, func(t *testing.T) {
- req := httptest.NewRequest(tc.method, tc.path, nil)
+ req := httptest.NewRequest(tc.method, tc.path, http.NoBody)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
@@ -215,7 +215,7 @@ func TestRegisterProviderAliases_V1BetaRoutes(t *testing.T) {
for _, tc := range v1betaPaths {
t.Run(tc.path, func(t *testing.T) {
- req := httptest.NewRequest(tc.method, tc.path, nil)
+ req := httptest.NewRequest(tc.method, tc.path, http.NoBody)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
@@ -236,7 +236,7 @@ func TestRegisterProviderAliases_NoAuthMiddleware(t *testing.T) {
m := &AmpModule{authMiddleware_: nil} // No auth middleware
m.registerProviderAliases(r, base, func(c *gin.Context) { c.AbortWithStatus(http.StatusOK) })
- req := httptest.NewRequest(http.MethodGet, "/api/provider/openai/models", nil)
+ req := httptest.NewRequest(http.MethodGet, "/api/provider/openai/models", http.NoBody)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
@@ -314,7 +314,7 @@ func TestLocalhostOnlyMiddleware_PreventsSpoofing(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
- req := httptest.NewRequest(http.MethodGet, "/test", nil)
+ req := httptest.NewRequest(http.MethodGet, "/test", http.NoBody)
req.RemoteAddr = tt.remoteAddr
if tt.forwardedFor != "" {
req.Header.Set("X-Forwarded-For", tt.forwardedFor)
@@ -346,7 +346,7 @@ func TestLocalhostOnlyMiddleware_HotReload(t *testing.T) {
})
// Test 1: Remote IP should be blocked when restriction is enabled
- req := httptest.NewRequest(http.MethodGet, "/test", nil)
+ req := httptest.NewRequest(http.MethodGet, "/test", http.NoBody)
req.RemoteAddr = "192.168.1.100:12345"
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
@@ -358,7 +358,7 @@ func TestLocalhostOnlyMiddleware_HotReload(t *testing.T) {
// Test 2: Hot-reload - disable restriction
m.setRestrictToLocalhost(false)
- req = httptest.NewRequest(http.MethodGet, "/test", nil)
+ req = httptest.NewRequest(http.MethodGet, "/test", http.NoBody)
req.RemoteAddr = "192.168.1.100:12345"
w = httptest.NewRecorder()
r.ServeHTTP(w, req)
@@ -370,7 +370,7 @@ func TestLocalhostOnlyMiddleware_HotReload(t *testing.T) {
// Test 3: Hot-reload - re-enable restriction
m.setRestrictToLocalhost(true)
- req = httptest.NewRequest(http.MethodGet, "/test", nil)
+ req = httptest.NewRequest(http.MethodGet, "/test", http.NoBody)
req.RemoteAddr = "192.168.1.100:12345"
w = httptest.NewRecorder()
r.ServeHTTP(w, req)
diff --git a/internal/api/server.go b/internal/api/server.go
index c7505dc2e70d7e2d33839235fa8882e26d3d79a9..3928ba712246e559ef78b558e63b20bca05dba19 100644
--- a/internal/api/server.go
+++ b/internal/api/server.go
@@ -201,6 +201,9 @@ func NewServer(cfg *config.Config, authManager *auth.Manager, accessManager *sdk
}
// Add middleware
+ engine.Use(middleware.RequestID())
+ engine.Use(middleware.SecurityHeaders())
+ engine.Use(middleware.ErrorHandler(log.StandardLogger()))
engine.Use(logging.GinLogrusLogger())
engine.Use(logging.GinLogrusRecovery())
for _, mw := range optionState.extraMiddleware {
diff --git a/internal/api/server_test.go b/internal/api/server_test.go
index 066532106f37f5a44a9ce21fc98ad8e3c215895a..cd203bb5674898346d211e93e71b8de8a2002aa1 100644
--- a/internal/api/server_test.go
+++ b/internal/api/server_test.go
@@ -94,7 +94,7 @@ func TestAmpProviderModelRoutes(t *testing.T) {
t.Run(tc.name, func(t *testing.T) {
server := newTestServer(t)
- req := httptest.NewRequest(http.MethodGet, tc.path, nil)
+ req := httptest.NewRequest(http.MethodGet, tc.path, http.NoBody)
req.Header.Set("Authorization", "Bearer test-key")
rr := httptest.NewRecorder()
diff --git a/internal/application/dto/config_dto.go b/internal/application/dto/config_dto.go
index 0775dcdbab9dd5a23c1208adc8bdc4f2ea800d12..e65a3bc6d21b9d357236e02d90b4059e8e94bd52 100644
--- a/internal/application/dto/config_dto.go
+++ b/internal/application/dto/config_dto.go
@@ -7,29 +7,29 @@ import (
// ConfigResponse represents a configuration response
type ConfigResponse struct {
- Debug bool `json:"debug"`
- UsageStatisticsEnabled bool `json:"usage_statistics_enabled"`
- LoggingToFile bool `json:"logging_to_file"`
- LogsMaxTotalSizeMB int `json:"logs_max_total_size_mb"`
- RequestLog bool `json:"request_log"`
- WebsocketAuth bool `json:"websocket_auth"`
- RequestRetry int `json:"request_retry"`
- MaxRetryInterval int `json:"max_retry_interval"`
- ForceModelPrefix bool `json:"force_model_prefix"`
- ProxyURL string `json:"proxy_url,omitempty"`
- Routing RoutingConfig `json:"routing"`
- RemoteManagement RemoteManagementConfig `json:"remote_management"`
- QuotaExceeded QuotaExceededConfig `json:"quota_exceeded"`
- APIKeys []string `json:"api_keys,omitempty"`
- GeminiKey []config.GeminiKey `json:"gemini_key,omitempty"`
- ClaudeKey []config.ClaudeKey `json:"claude_key,omitempty"`
- CodexKey []config.CodexKey `json:"codex_key,omitempty"`
- OpenAICompatibility []config.OpenAICompatibility `json:"openai_compatibility,omitempty"`
- VertexCompatAPIKey []config.VertexCompatKey `json:"vertex_compat_api_key,omitempty"`
- KiroKey []config.KiroKey `json:"kiro_key,omitempty"`
- OAuthExcludedModels map[string][]string `json:"oauth_excluded_models,omitempty"`
+ Debug bool `json:"debug"`
+ UsageStatisticsEnabled bool `json:"usage_statistics_enabled"`
+ LoggingToFile bool `json:"logging_to_file"`
+ LogsMaxTotalSizeMB int `json:"logs_max_total_size_mb"`
+ RequestLog bool `json:"request_log"`
+ WebsocketAuth bool `json:"websocket_auth"`
+ RequestRetry int `json:"request_retry"`
+ MaxRetryInterval int `json:"max_retry_interval"`
+ ForceModelPrefix bool `json:"force_model_prefix"`
+ ProxyURL string `json:"proxy_url,omitempty"`
+ Routing RoutingConfig `json:"routing"`
+ RemoteManagement RemoteManagementConfig `json:"remote_management"`
+ QuotaExceeded QuotaExceededConfig `json:"quota_exceeded"`
+ APIKeys []string `json:"api_keys,omitempty"`
+ GeminiKey []config.GeminiKey `json:"gemini_key,omitempty"`
+ ClaudeKey []config.ClaudeKey `json:"claude_key,omitempty"`
+ CodexKey []config.CodexKey `json:"codex_key,omitempty"`
+ OpenAICompatibility []config.OpenAICompatibility `json:"openai_compatibility,omitempty"`
+ VertexCompatAPIKey []config.VertexCompatKey `json:"vertex_compat_api_key,omitempty"`
+ KiroKey []config.KiroKey `json:"kiro_key,omitempty"`
+ OAuthExcludedModels map[string][]string `json:"oauth_excluded_models,omitempty"`
OAuthModelAlias map[string][]config.OAuthModelAlias `json:"oauth_model_alias,omitempty"`
- AmpCode config.AmpCode `json:"amp_code"`
+ AmpCode config.AmpCode `json:"amp_code"`
}
// RoutingConfig represents routing configuration
@@ -149,4 +149,4 @@ type BoolFieldUpdateRequest struct {
// IntFieldUpdateRequest represents a request to update an int field
type IntFieldUpdateRequest struct {
Value *int `json:"value"`
-}
\ No newline at end of file
+}
diff --git a/internal/application/dto/error.go b/internal/application/dto/error.go
new file mode 100644
index 0000000000000000000000000000000000000000..062da901337c724ade1a38e0b21dc9005c1b3486
--- /dev/null
+++ b/internal/application/dto/error.go
@@ -0,0 +1,21 @@
+package dto
+
+// ErrorResponse is the standardized error response for clients
+type ErrorResponse struct {
+ Success bool `json:"success" example:"false"`
+ Error ErrorInfo `json:"error"`
+ RequestID string `json:"request_id,omitempty"`
+}
+
+type ErrorInfo struct {
+ Code string `json:"code" example:"AUTH_INVALID_CREDENTIALS"`
+ Message string `json:"message" example:"Invalid credentials provided"`
+ Details map[string]interface{} `json:"details,omitempty"`
+}
+
+// InternalErrorResponse is returned for 500 errors (no sensitive info)
+type InternalErrorResponse struct {
+ Success bool `json:"success"`
+ Error string `json:"error"`
+ RequestID string `json:"request_id"`
+}
diff --git a/internal/application/mapper/config_mapper.go b/internal/application/mapper/config_mapper.go
index 853b994d5cbed90b181b88fd5ee63dd243939376..47acd35d627cedc0353e9ffbd21c9982d10089bc 100644
--- a/internal/application/mapper/config_mapper.go
+++ b/internal/application/mapper/config_mapper.go
@@ -103,4 +103,4 @@ func ToConfigFromResponse(resp *dto.ConfigResponse) *config.Config {
cfg.SDKConfig.APIKeys = resp.APIKeys
return cfg
-}
\ No newline at end of file
+}
diff --git a/internal/application/usecase/config_usecase.go b/internal/application/usecase/config_usecase.go
index 79ee1aac95bff288f89255023efed00bee35ec46..5b4a3ce981586a80a8b6a9896a3c0f3cb110d309 100644
--- a/internal/application/usecase/config_usecase.go
+++ b/internal/application/usecase/config_usecase.go
@@ -537,4 +537,4 @@ func (uc *ConfigUseCase) GetLatestVersion(ctx context.Context) (*dto.VersionResp
return &dto.VersionResponse{
LatestVersion: version,
}, nil
-}
\ No newline at end of file
+}
diff --git a/internal/auth/antigravity/auth.go b/internal/auth/antigravity/auth.go
index 449f413fc162147773d9669de29ffd638e07e006..fa56b597fd40d61cdc8fe3f9efa2125b22402446 100644
--- a/internal/auth/antigravity/auth.go
+++ b/internal/auth/antigravity/auth.go
@@ -113,7 +113,7 @@ func (o *AntigravityAuth) FetchUserInfo(ctx context.Context, accessToken string)
if accessToken == "" {
return "", fmt.Errorf("antigravity userinfo: missing access token")
}
- req, err := http.NewRequestWithContext(ctx, http.MethodGet, UserInfoEndpoint, nil)
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, UserInfoEndpoint, http.NoBody)
if err != nil {
return "", fmt.Errorf("antigravity userinfo: create request: %w", err)
}
diff --git a/internal/auth/codex/token.go b/internal/auth/codex/token.go
index e93fc41784b341d4172f1101b100a05121e9b935..7ae7dead35f8b52eea3b75d35543659997a6cb91 100644
--- a/internal/auth/codex/token.go
+++ b/internal/auth/codex/token.go
@@ -62,5 +62,4 @@ func (ts *CodexTokenStorage) SaveTokenToFile(authFilePath string) error {
return fmt.Errorf("failed to write token to file: %w", err)
}
return nil
-
}
diff --git a/internal/auth/gemini/gemini_auth.go b/internal/auth/gemini/gemini_auth.go
index 6406a0e15681d998f9a876143f734d70cb18832a..ae7b6df11e53a966ffa694b5752323c3c0b1db76 100644
--- a/internal/auth/gemini/gemini_auth.go
+++ b/internal/auth/gemini/gemini_auth.go
@@ -161,7 +161,7 @@ func (g *GeminiAuth) GetAuthenticatedClient(ctx context.Context, ts *GeminiToken
// - error: An error if the token storage creation fails, nil otherwise
func (g *GeminiAuth) createTokenStorage(ctx context.Context, config *oauth2.Config, token *oauth2.Token, projectID string) (*GeminiTokenStorage, error) {
httpClient := config.Client(ctx, token)
- req, err := http.NewRequestWithContext(ctx, "GET", "https://www.googleapis.com/oauth2/v1/userinfo?alt=json", nil)
+ req, err := http.NewRequestWithContext(ctx, "GET", "https://www.googleapis.com/oauth2/v1/userinfo?alt=json", http.NoBody)
if err != nil {
return nil, fmt.Errorf("could not get user info: %v", err)
}
diff --git a/internal/auth/iflow/iflow_auth.go b/internal/auth/iflow/iflow_auth.go
index fa9f38c3e61d62f26358da050e8dbf25ce428298..9b476181acf917ff698182c6fb94ce496a179e73 100644
--- a/internal/auth/iflow/iflow_auth.go
+++ b/internal/auth/iflow/iflow_auth.go
@@ -173,7 +173,7 @@ func (ia *IFlowAuth) FetchUserInfo(ctx context.Context, accessToken string) (*us
}
endpoint := fmt.Sprintf("%s?accessToken=%s", iFlowUserInfoEndpoint, url.QueryEscape(accessToken))
- req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, http.NoBody)
if err != nil {
return nil, fmt.Errorf("iflow api key: create request failed: %w", err)
}
@@ -334,7 +334,7 @@ func (ia *IFlowAuth) AuthenticateWithCookie(ctx context.Context, cookie string)
// fetchAPIKeyInfo retrieves API key information using GET request with cookie
func (ia *IFlowAuth) fetchAPIKeyInfo(ctx context.Context, cookie string) (*iFlowKeyData, error) {
- req, err := http.NewRequestWithContext(ctx, http.MethodGet, iFlowAPIKeyEndpoint, nil)
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, iFlowAPIKeyEndpoint, http.NoBody)
if err != nil {
return nil, fmt.Errorf("iflow cookie: create GET request failed: %w", err)
}
diff --git a/internal/cmd/login.go b/internal/cmd/login.go
index b5129cfd1aba2929217c4722d1a701f58a872141..d4959bf297a38b012236c5a1b9554d559acf7273 100644
--- a/internal/cmd/login.go
+++ b/internal/cmd/login.go
@@ -375,7 +375,7 @@ func callGeminiCLI(ctx context.Context, httpClient *http.Client, endpoint string
}
func fetchGCPProjects(ctx context.Context, httpClient *http.Client) ([]interfaces.GCPProjectProjects, error) {
- req, errRequest := http.NewRequestWithContext(ctx, http.MethodGet, "https://cloudresourcemanager.googleapis.com/v1/projects", nil)
+ req, errRequest := http.NewRequestWithContext(ctx, http.MethodGet, "https://cloudresourcemanager.googleapis.com/v1/projects", http.NoBody)
if errRequest != nil {
return nil, fmt.Errorf("could not create project list request: %w", errRequest)
}
@@ -559,7 +559,7 @@ func checkCloudAPIIsEnabled(ctx context.Context, httpClient *http.Client, projec
}
for _, service := range requiredServices {
checkUrl := fmt.Sprintf("%s/v1/projects/%s/services/%s", serviceUsageURL, projectID, service)
- req, errRequest := http.NewRequestWithContext(ctx, http.MethodGet, checkUrl, nil)
+ req, errRequest := http.NewRequestWithContext(ctx, http.MethodGet, checkUrl, http.NoBody)
if errRequest != nil {
return false, fmt.Errorf("failed to create request: %w", errRequest)
}
diff --git a/internal/domain/errors/errors.go b/internal/domain/errors/errors.go
index 14694c65cfbcfe271e36d656b50662a1a3c1727d..39d8a0fc13f028b0dd153fa40f6d57fd43068460 100644
--- a/internal/domain/errors/errors.go
+++ b/internal/domain/errors/errors.go
@@ -32,6 +32,37 @@ const (
ValidationFailed ErrorCode = "VALIDATION_FAILED"
// AlreadyExists indicates a resource already exists
AlreadyExists ErrorCode = "ALREADY_EXISTS"
+
+ // New Error Codes (Standardization Plan)
+ // Authentication errors
+ ErrCodeInvalidCredentials ErrorCode = "AUTH_INVALID_CREDENTIALS"
+ ErrCodeTokenExpired ErrorCode = "AUTH_TOKEN_EXPIRED"
+ ErrCodeTokenInvalid ErrorCode = "AUTH_TOKEN_INVALID"
+ ErrCodeUnauthorized ErrorCode = "AUTH_UNAUTHORIZED"
+
+ // Configuration errors
+ ErrCodeConfigNotFound ErrorCode = "CONFIG_NOT_FOUND"
+ ErrCodeConfigInvalid ErrorCode = "CONFIG_INVALID"
+ ErrCodeConfigValidation ErrorCode = "CONFIG_VALIDATION_FAILED"
+
+ // Provider errors
+ ErrCodeProviderNotFound ErrorCode = "PROVIDER_NOT_FOUND"
+ ErrCodeProviderUnavailable ErrorCode = "PROVIDER_UNAVAILABLE"
+ ErrCodeProviderRateLimited ErrorCode = "PROVIDER_RATE_LIMITED"
+
+ // Request errors
+ ErrCodeInvalidRequest ErrorCode = "REQUEST_INVALID"
+ ErrCodeMissingField ErrorCode = "REQUEST_MISSING_FIELD"
+ ErrCodeInvalidFormat ErrorCode = "REQUEST_INVALID_FORMAT"
+
+ // Storage errors
+ ErrCodeStorageFailure ErrorCode = "STORAGE_FAILURE"
+ ErrCodeNotFound ErrorCode = "RESOURCE_NOT_FOUND"
+ ErrCodeConflict ErrorCode = "RESOURCE_CONFLICT"
+
+ // Internal errors
+ ErrCodeInternal ErrorCode = "INTERNAL_ERROR"
+ ErrCodeNotImplemented ErrorCode = "NOT_IMPLEMENTED"
)
// DomainError is the base error type for all domain errors.
@@ -40,8 +71,9 @@ const (
type DomainError struct {
Code ErrorCode
Message string
- Cause error
Details map[string]interface{}
+ Cause error
+ HTTPStatus int
}
// Error implements the error interface
@@ -184,6 +216,9 @@ func NewTimeoutError(operation string) *DomainError {
// HTTPStatusCode returns the appropriate HTTP status code for the error
func (e *DomainError) HTTPStatusCode() int {
+ if e.HTTPStatus > 0 {
+ return e.HTTPStatus
+ }
switch e.Code {
case NotFound:
return 404
@@ -216,26 +251,76 @@ func (e *DomainError) ToResponse() map[string]interface{} {
return response
}
+// NewInvalidCredentials creates an AUTH_INVALID_CREDENTIALS error
+func NewInvalidCredentials(msg string) *DomainError {
+ return &DomainError{
+ Code: ErrCodeInvalidCredentials,
+ Message: msg,
+ HTTPStatus: 401,
+ }
+}
+
+// NewConfigNotFound creates a CONFIG_NOT_FOUND error
+func NewConfigNotFound(resource string) *DomainError {
+ return &DomainError{
+ Code: ErrCodeConfigNotFound,
+ Message: fmt.Sprintf("configuration not found: %s", resource),
+ HTTPStatus: 404,
+ Details: map[string]interface{}{"resource": resource},
+ }
+}
+
+// NewProviderUnavailable creates a PROVIDER_UNAVAILABLE error
+func NewProviderUnavailable(provider string, cause error) *DomainError {
+ return &DomainError{
+ Code: ErrCodeProviderUnavailable,
+ Message: fmt.Sprintf("provider %s is unavailable", provider),
+ HTTPStatus: 503,
+ Cause: cause,
+ Details: map[string]interface{}{"provider": provider},
+ }
+}
+
+// NewConfigValidationError creates a CONFIG_VALIDATION_FAILED error
+func NewConfigValidationError(field string, msg string) *DomainError {
+ return &DomainError{
+ Code: ErrCodeConfigValidation,
+ Message: fmt.Sprintf("validation failed for %s: %s", field, msg),
+ HTTPStatus: 400,
+ Details: map[string]interface{}{"field": field},
+ }
+}
+
+// NewInternalErrorWrapped creates an INTERNAL_ERROR
+func NewInternalErrorWrapped(cause error) *DomainError {
+ return &DomainError{
+ Code: ErrCodeInternal,
+ Message: "an internal error occurred",
+ HTTPStatus: 500,
+ Cause: cause,
+ }
+}
+
// Common error instances for reuse
var (
// ErrConfigNotFound is returned when configuration is not found
ErrConfigNotFound = New(NotFound, "configuration not found")
-
+
// ErrAuthFileNotFound is returned when an auth file is not found
ErrAuthFileNotFound = New(NotFound, "auth file not found")
-
+
// ErrInvalidConfig is returned when configuration is invalid
ErrInvalidConfig = New(ValidationFailed, "invalid configuration")
-
+
// ErrAuthManagerUnavailable is returned when the auth manager is not available
ErrAuthManagerUnavailable = New(ServiceUnavailable, "auth manager unavailable")
-
+
// ErrTokenStoreUnavailable is returned when the token store is not available
ErrTokenStoreUnavailable = New(ServiceUnavailable, "token store unavailable")
-
+
// ErrLogDirectoryNotConfigured is returned when log directory is not set
ErrLogDirectoryNotConfigured = New(InternalError, "log directory not configured")
-
+
// ErrLoggingDisabled is returned when logging to file is disabled
ErrLoggingDisabled = New(ServiceUnavailable, "logging to file disabled")
-)
\ No newline at end of file
+)
diff --git a/internal/domain/ports/rate_limit.go b/internal/domain/ports/rate_limit.go
deleted file mode 100644
index df198ee2ab78c04abfb41b2bc0f50c86428addde..0000000000000000000000000000000000000000
--- a/internal/domain/ports/rate_limit.go
+++ /dev/null
@@ -1,43 +0,0 @@
-package ports
-
-import (
- "context"
- "time"
-)
-
-// RateLimitEntry represents the state of a rate limit for a key (e.g., IP address).
-type RateLimitEntry struct {
- Key string
- Count int // Current count of attempts or tokens used
- LastAttempt time.Time // Timestamp of the last attempt
- BlockedUntil time.Time // Time until which the key is blocked (zero time if not blocked)
-}
-
-// RateLimitRepository defines the interface for persisting rate limit data.
-type RateLimitRepository interface {
- // Get retrieves the rate limit entry for a given key.
- Get(ctx context.Context, key string) (*RateLimitEntry, error)
-
- // Set saves the rate limit entry for a given key with an expiration.
- Set(ctx context.Context, key string, entry *RateLimitEntry, expiration time.Duration) error
-
- // Cleanup removes entries older than the specified time.
- Cleanup(ctx context.Context, olderThan time.Time) error
-}
-
-// RateLimitService defines the interface for the rate limiting logic.
-type RateLimitService interface {
- // Allow checks if a request from the given key is allowed based on the rate limit policy.
- // It basically checks if the key is currently blocked.
- Allow(ctx context.Context, key string) (bool, error)
-
- // RecordAttempt records a request or action attempt for the given key.
- // success: indicates if the attempt was successful.
- // If success is true, it might reset the failure count.
- // If success is false, it increments the failure count and might block the key.
- RecordAttempt(ctx context.Context, key string, success bool) error
-
- // IsBlocked checks if the key is currently blocked and returns the blockage details.
- // Returns true if blocked, the time until it's blocked, and any error.
- IsBlocked(ctx context.Context, key string) (bool, time.Time, error)
-}
diff --git a/internal/domain/ports/repositories.go b/internal/domain/ports/repositories.go
index f6e97b49902506e5d54293e758f723b1c14d6579..3d57dc683dc01e57a77b0e3fdc0175d266d5b867 100644
--- a/internal/domain/ports/repositories.go
+++ b/internal/domain/ports/repositories.go
@@ -15,67 +15,67 @@ import (
type ConfigRepository interface {
// Load retrieves the current configuration
Load(ctx context.Context) (*config.Config, error)
-
+
// Save persists the configuration
Save(ctx context.Context, cfg *config.Config) error
-
+
// SaveWithPath persists the configuration to a specific path
SaveWithPath(ctx context.Context, path string, cfg *config.Config) error
-
+
// Validate validates the configuration without saving
Validate(ctx context.Context, cfg *config.Config) error
-
+
// GetConfigPath returns the current configuration file path
GetConfigPath() string
}
// AuthFile represents an authentication file in the domain
type AuthFile struct {
- ID string
- Provider string
- FileName string
- Label string
- Email string
- Status string
- StatusMessage string
- Disabled bool
- Unavailable bool
- RuntimeOnly bool
- Path string
- Size int64
- CreatedAt time.Time
- UpdatedAt time.Time
+ ID string
+ Provider string
+ FileName string
+ Label string
+ Email string
+ Status string
+ StatusMessage string
+ Disabled bool
+ Unavailable bool
+ RuntimeOnly bool
+ Path string
+ Size int64
+ CreatedAt time.Time
+ UpdatedAt time.Time
LastRefreshedAt time.Time
- Metadata map[string]interface{}
- Attributes map[string]string
+ Metadata map[string]interface{}
+ Attributes map[string]string
}
// AuthRepository defines the interface for authentication file persistence
type AuthRepository interface {
// List retrieves all authentication files
List(ctx context.Context) ([]*AuthFile, error)
-
+
// GetByID retrieves an authentication file by its ID
GetByID(ctx context.Context, id string) (*AuthFile, error)
-
+
// GetByName retrieves an authentication file by its filename
GetByName(ctx context.Context, name string) (*AuthFile, error)
-
+
// Save persists an authentication file
Save(ctx context.Context, file *AuthFile) error
-
+
// Delete removes an authentication file
Delete(ctx context.Context, id string) error
-
+
// DeleteAll removes all authentication files
DeleteAll(ctx context.Context) (int, error)
-
+
// Disable marks an authentication file as disabled
Disable(ctx context.Context, id string, reason string) error
-
+
// Enable marks an authentication file as enabled
Enable(ctx context.Context, id string) error
-
+
// GetAuthDir returns the authentication directory path
GetAuthDir() string
}
@@ -94,25 +94,25 @@ type LogEntry struct {
type LogRepository interface {
// ListLogFiles retrieves all log files
ListLogFiles(ctx context.Context) ([]*LogFileInfo, error)
-
+
// ReadLogFile reads a log file with optional filtering
ReadLogFile(ctx context.Context, filename string, after int64, limit int) (*LogContent, error)
-
+
// DeleteLogFiles removes all log files and truncates the active log
DeleteLogFiles(ctx context.Context) (*DeleteLogResult, error)
-
+
// GetRequestErrorLogs retrieves error request log files
GetRequestErrorLogs(ctx context.Context) ([]*LogFileInfo, error)
-
+
// GetRequestLogByID retrieves a specific request log by ID
GetRequestLogByID(ctx context.Context, requestID string) ([]byte, error)
-
+
// DownloadRequestErrorLog downloads a specific error log file
DownloadRequestErrorLog(ctx context.Context, filename string) ([]byte, error)
-
+
// GetLogDirectory returns the log directory path
GetLogDirectory() string
-
+
// IsLoggingEnabled returns whether logging to file is enabled
IsLoggingEnabled() bool
}
@@ -155,35 +155,35 @@ type TokenRecord struct {
type TokenStore interface {
// Save persists a token record
Save(ctx context.Context, record *TokenRecord) (string, error)
-
+
// Delete removes a token record
Delete(ctx context.Context, path string) error
-
+
// Get retrieves a token record by path
Get(ctx context.Context, path string) (*TokenRecord, error)
-
+
// List retrieves all token records
List(ctx context.Context) ([]*TokenRecord, error)
}
// UsageStatistics represents usage statistics data
type UsageStatistics struct {
- TotalRequests int64
- FailureCount int64
- RequestCount int64
- TokenCount int64
- LastUpdated time.Time
- Data map[string]interface{}
+ TotalRequests int64
+ FailureCount int64
+ RequestCount int64
+ TokenCount int64
+ LastUpdated time.Time
+ Data map[string]interface{}
}
// UsageRepository defines the interface for usage statistics persistence
type UsageRepository interface {
// GetStatistics retrieves current usage statistics
GetStatistics(ctx context.Context) (*UsageStatistics, error)
-
+
// ExportStatistics exports statistics for backup
ExportStatistics(ctx context.Context) (*UsageStatistics, error)
-
+
// ImportStatistics imports statistics from backup
ImportStatistics(ctx context.Context, stats *UsageStatistics) (*ImportResult, error)
}
@@ -197,34 +197,34 @@ type ImportResult struct {
// OAuthSession represents an OAuth session
type OAuthSession struct {
- State string
- Provider string
- Status string
- Error string
- CreatedAt time.Time
- ExpiresAt time.Time
+ State string
+ Provider string
+ Status string
+ Error string
+ CreatedAt time.Time
+ ExpiresAt time.Time
}
// OAuthSessionRepository defines the interface for OAuth session management
type OAuthSessionRepository interface {
// Create creates a new OAuth session
Create(ctx context.Context, session *OAuthSession) error
-
+
// Get retrieves an OAuth session by state
Get(ctx context.Context, state string) (*OAuthSession, error)
-
+
// Update updates an OAuth session
Update(ctx context.Context, session *OAuthSession) error
-
+
// Complete marks an OAuth session as complete
Complete(ctx context.Context, state string) error
-
+
// SetError sets an error on an OAuth session
SetError(ctx context.Context, state string, err string) error
-
+
// IsPending checks if a session is pending
IsPending(ctx context.Context, state string) bool
-
+
// Cleanup removes expired sessions
Cleanup(ctx context.Context) error
-}
\ No newline at end of file
+}
diff --git a/internal/domain/ports/services.go b/internal/domain/ports/services.go
index 8c86112f369a12f13e5721c5f1fc7c35772a1267..bf29f467eb370e9168b47144a96c0d08c9acadfa 100644
--- a/internal/domain/ports/services.go
+++ b/internal/domain/ports/services.go
@@ -13,97 +13,97 @@ import (
type ConfigService interface {
// GetConfig retrieves the current configuration
GetConfig(ctx context.Context) (*config.Config, error)
-
+
// UpdateConfig updates the entire configuration
UpdateConfig(ctx context.Context, cfg *config.Config) error
-
+
// UpdateField updates a single configuration field
UpdateField(ctx context.Context, field string, value interface{}) error
-
+
// UpdateAPIKeys updates the API keys list
UpdateAPIKeys(ctx context.Context, keys []string) error
-
+
// UpdateGeminiKeys updates the Gemini keys list
UpdateGeminiKeys(ctx context.Context, keys []config.GeminiKey) error
-
+
// UpdateClaudeKeys updates the Claude keys list
UpdateClaudeKeys(ctx context.Context, keys []config.ClaudeKey) error
-
+
// UpdateCodexKeys updates the Codex keys list
UpdateCodexKeys(ctx context.Context, keys []config.CodexKey) error
-
+
// UpdateOpenAICompatibility updates the OpenAI compatibility entries
UpdateOpenAICompatibility(ctx context.Context, entries []config.OpenAICompatibility) error
-
+
// UpdateVertexCompatKeys updates the Vertex compatibility keys
UpdateVertexCompatKeys(ctx context.Context, keys []config.VertexCompatKey) error
-
+
// UpdateKiroKeys updates the Kiro keys list
UpdateKiroKeys(ctx context.Context, keys []config.KiroKey) error
-
+
// UpdateOAuthExcludedModels updates OAuth excluded models
UpdateOAuthExcludedModels(ctx context.Context, models map[string][]string) error
-
+
// UpdateOAuthModelAlias updates OAuth model aliases
UpdateOAuthModelAlias(ctx context.Context, aliases map[string][]config.OAuthModelAlias) error
-
+
// UpdateAmpCode updates the AmpCode configuration
UpdateAmpCode(ctx context.Context, ampCode config.AmpCode) error
-
+
// UpdateAmpUpstreamURL updates the Amp upstream URL
UpdateAmpUpstreamURL(ctx context.Context, url string) error
-
+
// UpdateAmpModelMappings updates Amp model mappings
UpdateAmpModelMappings(ctx context.Context, mappings []config.AmpModelMapping) error
-
+
// UpdateAmpUpstreamAPIKeys updates Amp upstream API keys
UpdateAmpUpstreamAPIKeys(ctx context.Context, keys []config.AmpUpstreamAPIKeyEntry) error
-
+
// UpdateDebug updates the debug setting
UpdateDebug(ctx context.Context, enabled bool) error
-
+
// UpdateUsageStatisticsEnabled updates the usage statistics enabled setting
UpdateUsageStatisticsEnabled(ctx context.Context, enabled bool) error
-
+
// UpdateLoggingToFile updates the logging to file setting
UpdateLoggingToFile(ctx context.Context, enabled bool) error
-
+
// UpdateLogsMaxTotalSizeMB updates the max log size
UpdateLogsMaxTotalSizeMB(ctx context.Context, sizeMB int) error
-
+
// UpdateRequestLog updates the request log setting
UpdateRequestLog(ctx context.Context, enabled bool) error
-
+
// UpdateWebsocketAuth updates the websocket auth setting
UpdateWebsocketAuth(ctx context.Context, enabled bool) error
-
+
// UpdateRequestRetry updates the request retry count
UpdateRequestRetry(ctx context.Context, retry int) error
-
+
// UpdateMaxRetryInterval updates the max retry interval
UpdateMaxRetryInterval(ctx context.Context, interval int) error
-
+
// UpdateForceModelPrefix updates the force model prefix setting
UpdateForceModelPrefix(ctx context.Context, enabled bool) error
-
+
// UpdateRoutingStrategy updates the routing strategy
UpdateRoutingStrategy(ctx context.Context, strategy string) error
-
+
// UpdateProxyURL updates the proxy URL
UpdateProxyURL(ctx context.Context, url string) error
-
+
// UpdateRemoteManagement updates the remote management settings
UpdateRemoteManagement(ctx context.Context, allowRemote bool, secretHash string) error
-
+
// UpdateQuotaExceeded updates the quota exceeded settings
UpdateQuotaExceeded(ctx context.Context, switchProject, switchPreviewModel bool) error
-
+
// Validate validates the current configuration
Validate(ctx context.Context) error
-
+
// ValidateConfig validates a specific configuration
ValidateConfig(ctx context.Context, cfg *config.Config) error
-
+
// GetLatestVersion retrieves the latest version from GitHub
GetLatestVersion(ctx context.Context) (string, error)
}
@@ -112,31 +112,31 @@ type ConfigService interface {
type AuthFileService interface {
// ListAuthFiles retrieves all authentication files
ListAuthFiles(ctx context.Context) ([]*AuthFile, error)
-
+
// GetAuthFile retrieves a single authentication file by ID
GetAuthFile(ctx context.Context, id string) (*AuthFile, error)
-
+
// GetAuthFileModels retrieves models supported by an auth file
GetAuthFileModels(ctx context.Context, id string) ([]*AuthFileModel, error)
-
+
// UploadAuthFile uploads a new authentication file
UploadAuthFile(ctx context.Context, filename string, data []byte) (*AuthFile, error)
-
+
// DownloadAuthFile retrieves the raw content of an auth file
DownloadAuthFile(ctx context.Context, id string) ([]byte, error)
-
+
// DeleteAuthFile deletes an authentication file
DeleteAuthFile(ctx context.Context, id string) error
-
+
// DeleteAllAuthFiles deletes all authentication files
DeleteAllAuthFiles(ctx context.Context) (int, error)
-
+
// DisableAuthFile disables an authentication file
DisableAuthFile(ctx context.Context, id string) error
-
+
// EnableAuthFile enables an authentication file
EnableAuthFile(ctx context.Context, id string) error
-
+
// RefreshAuthToken refreshes the token for an auth file
RefreshAuthToken(ctx context.Context, id string) error
}
@@ -153,16 +153,16 @@ type AuthFileModel struct {
type LogService interface {
// GetLogs retrieves log entries with optional filtering
GetLogs(ctx context.Context, after int64, limit int) (*LogContent, error)
-
+
// DeleteLogs removes all log files
DeleteLogs(ctx context.Context) (*DeleteLogResult, error)
-
+
// GetRequestErrorLogs retrieves error request log files
GetRequestErrorLogs(ctx context.Context) ([]*LogFileInfo, error)
-
+
// GetRequestLogByID retrieves a specific request log by ID
GetRequestLogByID(ctx context.Context, requestID string) ([]byte, error)
-
+
// DownloadRequestErrorLog downloads a specific error log file
DownloadRequestErrorLog(ctx context.Context, filename string) ([]byte, error)
}
@@ -171,10 +171,10 @@ type LogService interface {
type UsageService interface {
// GetUsageStatistics retrieves current usage statistics
GetUsageStatistics(ctx context.Context) (*UsageStatistics, error)
-
+
// ExportUsageStatistics exports statistics for backup
ExportUsageStatistics(ctx context.Context) (*UsageStatistics, error)
-
+
// ImportUsageStatistics imports statistics from backup
ImportUsageStatistics(ctx context.Context, stats *UsageStatistics) (*ImportResult, error)
}
@@ -183,13 +183,13 @@ type UsageService interface {
type OAuthService interface {
// InitiateAuth initiates OAuth authentication for a provider
InitiateAuth(ctx context.Context, provider string, options *OAuthOptions) (*OAuthInitResult, error)
-
+
// CompleteAuth completes OAuth authentication with a code
CompleteAuth(ctx context.Context, state string, code string) (*AuthFile, error)
-
+
// GetAuthStatus retrieves the status of an OAuth session
GetAuthStatus(ctx context.Context, state string) (*OAuthSessionStatus, error)
-
+
// CancelAuth cancels an ongoing OAuth session
CancelAuth(ctx context.Context, state string) error
}
@@ -209,26 +209,26 @@ type OAuthInitResult struct {
// OAuthSessionStatus represents the status of an OAuth session
type OAuthSessionStatus struct {
- State string
- Status string // "pending", "complete", "error"
- Error string
- Provider string
+ State string
+ Status string // "pending", "complete", "error"
+ Error string
+ Provider string
}
// ManagementService defines operations for management functionality
type ManagementService interface {
// VerifyManagementKey verifies a management key
VerifyManagementKey(ctx context.Context, key string, clientIP string) error
-
+
// IsRemoteAllowed checks if remote management is allowed for a client
IsRemoteAllowed(ctx context.Context, clientIP string) bool
-
+
// RecordFailedAttempt records a failed authentication attempt
RecordFailedAttempt(ctx context.Context, clientIP string)
-
+
// IsBlocked checks if a client IP is blocked
IsBlocked(ctx context.Context, clientIP string) (bool, string)
-
+
// GetVersionInfo retrieves version information
GetVersionInfo(ctx context.Context) (*VersionInfo, error)
}
@@ -244,18 +244,18 @@ type VersionInfo struct {
type APICallService interface {
// MakeAPICall makes a generic HTTP API call
MakeAPICall(ctx context.Context, req *APICallRequest) (*APICallResponse, error)
-
+
// ResolveToken resolves a token for an auth index
ResolveToken(ctx context.Context, authIndex string) (string, error)
}
// APICallRequest contains parameters for an API call
type APICallRequest struct {
- AuthIndex string
- Method string
- URL string
- Headers map[string]string
- Body string
+ AuthIndex string
+ Method string
+ URL string
+ Headers map[string]string
+ Body string
}
// APICallResponse contains the response from an API call
@@ -263,4 +263,4 @@ type APICallResponse struct {
StatusCode int
Headers map[string][]string
Body string
-}
\ No newline at end of file
+}
diff --git a/internal/domain/services/auth_service.go b/internal/domain/services/auth_service.go
index 885dc8691a846c3f47ee701b47db4936ad0b8fe8..5dabbe15b9d457b03d009e2a205bb6a7cb0ff51f 100644
--- a/internal/domain/services/auth_service.go
+++ b/internal/domain/services/auth_service.go
@@ -319,4 +319,4 @@ func (s *AuthService) RefreshAuthToken(ctx context.Context, id string) error {
}
// Ensure AuthService implements the interface
-var _ ports.AuthFileService = (*AuthService)(nil)
\ No newline at end of file
+var _ ports.AuthFileService = (*AuthService)(nil)
diff --git a/internal/domain/services/config_service.go b/internal/domain/services/config_service.go
index 82fb6d9fe045b95138e08593f887a0cc9f1fe71c..7f574d01be0f2ad61269f8bf4ab2e1ab7df28af1 100644
--- a/internal/domain/services/config_service.go
+++ b/internal/domain/services/config_service.go
@@ -508,7 +508,7 @@ func (s *ConfigService) GetLatestVersion(ctx context.Context) (string, error) {
util.SetProxy(sdkCfg, client)
}
- req, err := http.NewRequestWithContext(ctx, http.MethodGet, latestReleaseURL, nil)
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, latestReleaseURL, http.NoBody)
if err != nil {
return "", errors.Wrap(errors.InternalError, "failed to create request", err)
}
@@ -674,4 +674,4 @@ func toInt(v interface{}) (int, bool) {
}
// Ensure ConfigService implements the interface
-var _ ports.ConfigService = (*ConfigService)(nil)
\ No newline at end of file
+var _ ports.ConfigService = (*ConfigService)(nil)
diff --git a/internal/domain/services/log_service.go b/internal/domain/services/log_service.go
index 5ab3b3f4b88a76a2b0e9aa59a60bf79c9269a4ac..8c342d5763c9699a61b3005b7ff13a56130e629a 100644
--- a/internal/domain/services/log_service.go
+++ b/internal/domain/services/log_service.go
@@ -128,4 +128,4 @@ func (s *LogService) DownloadRequestErrorLog(ctx context.Context, filename strin
}
// Ensure LogService implements the interface
-var _ ports.LogService = (*LogService)(nil)
\ No newline at end of file
+var _ ports.LogService = (*LogService)(nil)
diff --git a/internal/domain/services/rate_limit_service.go b/internal/domain/services/rate_limit_service.go
deleted file mode 100644
index 537527699370baa540a0b5abf4b7f67b96f3789a..0000000000000000000000000000000000000000
--- a/internal/domain/services/rate_limit_service.go
+++ /dev/null
@@ -1,153 +0,0 @@
-package services
-
-import (
- "context"
- "time"
-
- "github.com/router-for-me/CLIProxyAPI/v6/internal/domain/ports"
-)
-
-// RateLimitConfig holds the configuration for the RateLimitService.
-type RateLimitConfig struct {
- MaxFailures int // Maximum number of failures allowed before blocking
- FailureDecayInterval time.Duration // Time duration to forgive one failure (leak rate)
- BlockDuration time.Duration // Duration to block the key after MaxFailures is reached
-}
-
-// RateLimitService implements the RateLimitService interface.
-type RateLimitService struct {
- repo ports.RateLimitRepository
- config RateLimitConfig
- // now returns the current time. It is a field to allow mocking in tests.
- now func() time.Time
-}
-
-// NewRateLimitService creates a new instance of RateLimitService.
-func NewRateLimitService(repo ports.RateLimitRepository, config RateLimitConfig) *RateLimitService {
- return &RateLimitService{
- repo: repo,
- config: config,
- now: time.Now,
- }
-}
-
-// Allow checks if the request is allowed.
-func (s *RateLimitService) Allow(ctx context.Context, key string) (bool, error) {
- blocked, _, err := s.IsBlocked(ctx, key)
- if err != nil {
- return false, err
- }
- return !blocked, nil
-}
-
-// IsBlocked checks if the key is currently blocked.
-func (s *RateLimitService) IsBlocked(ctx context.Context, key string) (bool, time.Time, error) {
- entry, err := s.repo.Get(ctx, key)
- if err != nil {
- return false, time.Time{}, err
- }
- if entry == nil {
- return false, time.Time{}, nil
- }
-
- // Check if blocked
- if !entry.BlockedUntil.IsZero() {
- if s.now().After(entry.BlockedUntil) {
- // Block has expired.
- // Ideally, we should clear the block state in the repo, but "Get" is read-only.
- // The next RecordAttempt will clean it up or we can lazily accept it as allowed.
- return false, time.Time{}, nil
- }
- return true, entry.BlockedUntil, nil
- }
-
- return false, time.Time{}, nil
-}
-
-// RecordAttempt records the result of an action.
-func (s *RateLimitService) RecordAttempt(ctx context.Context, key string, success bool) error {
- entry, err := s.repo.Get(ctx, key)
- if err != nil {
- return err
- }
-
- now := s.now()
-
- if entry == nil {
- entry = &ports.RateLimitEntry{
- Key: key,
- LastAttempt: now,
- }
- }
-
- // If the block has expired, reset the state
- if !entry.BlockedUntil.IsZero() && now.After(entry.BlockedUntil) {
- entry.BlockedUntil = time.Time{}
- entry.Count = 0 // Reset count after block expiry
- entry.LastAttempt = now
- }
-
- // If currently blocked, we might choose to extend or just return.
- // For now, if blocked, we don't count further failures (or we could).
- // Let's assume we don't process attempts while blocked (caller should have checked Allow).
- if !entry.BlockedUntil.IsZero() && now.Before(entry.BlockedUntil) {
- // Still blocked, nothing to update?
- // Or should we extend? Let's just keep the existing block.
- return nil
- }
-
- // Apply Leaky Bucket Logic (Decay)
- if s.config.FailureDecayInterval > 0 {
- elapsed := now.Sub(entry.LastAttempt)
- decay := int(elapsed / s.config.FailureDecayInterval)
- if decay > 0 {
- entry.Count -= decay
- if entry.Count < 0 {
- entry.Count = 0
- }
- // We effectively used up the time for these decays.
- // To be precise with remaining time, we could adjust LastAttempt,
- // but for simplicity, we'll just set LastAttempt to now at the end if we update.
- }
- }
-
- if success {
- // On success, we generally don't increase failure count.
- // We could decrease it (reward) or just let time decay it.
- // Let's just update LastAttempt to keep the record alive and accurate for decay calculation.
- // Actually, if we update LastAttempt without reducing count (via decay), we stop the decay from happening?
- // Wait.
- // T0: Count=5. Last=T0.
- // T10 (Decay=10s): Success. Elapsed=10s. Decay=1. Count=4. Last=T10.
- // This works. We applied the decay that happened during the interval.
- // So yes, we should run the decay logic and update LastAttempt even on success.
- entry.LastAttempt = now
- } else {
- // Failure
- entry.Count++
- entry.LastAttempt = now
-
- if entry.Count >= s.config.MaxFailures {
- entry.BlockedUntil = now.Add(s.config.BlockDuration)
- // Reset count or keep it at max?
- // Often helpful to keep it at max or 0.
- // If we keep it at max, future failures after unblock will immediately reblock?
- // That depends on if we reset on unblock (handled above).
- }
- }
-
-
- // Calculate TTL for the storage entry
- // It should survive at least until block expires OR until count decays to 0.
- ttl := s.config.BlockDuration
- if s.config.FailureDecayInterval > 0 {
- decayTTL := time.Duration(entry.Count) * s.config.FailureDecayInterval
- if decayTTL > ttl {
- ttl = decayTTL
- }
- }
- // Add a buffer to TTL
- ttl += time.Minute
-
- return s.repo.Set(ctx, key, entry, ttl)
-}
diff --git a/internal/domain/services/rate_limit_service_test.go b/internal/domain/services/rate_limit_service_test.go
deleted file mode 100644
index 1c7e01d86aaf7d941f8918dd72761dca7ebec57c..0000000000000000000000000000000000000000
--- a/internal/domain/services/rate_limit_service_test.go
+++ /dev/null
@@ -1,200 +0,0 @@
-package services
-
-import (
- "context"
- "testing"
- "time"
-
- "github.com/router-for-me/CLIProxyAPI/v6/internal/domain/ports"
- "github.com/router-for-me/CLIProxyAPI/v6/internal/infrastructure/persistence"
- "github.com/stretchr/testify/assert"
-)
-
-func TestRateLimitService(t *testing.T) {
- repo := persistence.NewInMemoryRateLimitRepository()
- config := RateLimitConfig{
- MaxFailures: 3,
- FailureDecayInterval: time.Minute,
- BlockDuration: 10 * time.Minute,
- }
-
- service := NewRateLimitService(repo, config)
-
- // Mock time
- currentTime := time.Date(2023, 1, 1, 10, 0, 0, 0, time.UTC)
- service.now = func() time.Time {
- return currentTime
- }
-
- ctx := context.Background()
- key := "127.0.0.1"
-
- // 1. Initial State: Allowed
- allowed, err := service.Allow(ctx, key)
- assert.NoError(t, err)
- assert.True(t, allowed, "Initial state should be allowed")
-
- // 2. Record 2 Failures
- err = service.RecordAttempt(ctx, key, false)
- assert.NoError(t, err)
- err = service.RecordAttempt(ctx, key, false)
- assert.NoError(t, err)
-
- // Still allowed
- allowed, err = service.Allow(ctx, key)
- assert.NoError(t, err)
- assert.True(t, allowed, "Should be allowed after 2 failures (max 3)")
-
- // Check underlying state (optional, white-box testing)
- entry, _ := repo.Get(ctx, key)
- assert.Equal(t, 2, entry.Count)
-
- // 3. Record 3rd Failure -> Blocked
- err = service.RecordAttempt(ctx, key, false)
- assert.NoError(t, err)
-
- allowed, err = service.Allow(ctx, key)
- assert.NoError(t, err)
- assert.False(t, allowed, "Should be blocked after 3 failures")
-
- isBlocked, until, err := service.IsBlocked(ctx, key)
- assert.NoError(t, err)
- assert.True(t, isBlocked)
- assert.Equal(t, currentTime.Add(config.BlockDuration), until)
-
- // 4. Advance time past block duration
- currentTime = currentTime.Add(config.BlockDuration).Add(time.Second)
-
- allowed, err = service.Allow(ctx, key)
- assert.NoError(t, err)
- assert.True(t, allowed, "Should be allowed after block expires")
-
- // Record an attempt after expiry - should reset logic
- err = service.RecordAttempt(ctx, key, true) // Success attempt
- assert.NoError(t, err)
-
- entry, _ = repo.Get(ctx, key)
- assert.Equal(t, 0, entry.Count, "Count should be reset after block expiry")
-
- // 5. Test Decay
- // Reset
- currentTime = time.Date(2023, 1, 1, 12, 0, 0, 0, time.UTC)
- repo = persistence.NewInMemoryRateLimitRepository()
- service = NewRateLimitService(repo, config)
- service.now = func() time.Time { return currentTime }
-
- // 2 failures
- service.RecordAttempt(ctx, key, false)
- service.RecordAttempt(ctx, key, false)
-
- entry, _ = repo.Get(ctx, key)
- assert.Equal(t, 2, entry.Count)
-
- // Advance time by 1 minute (1 decay interval)
- currentTime = currentTime.Add(time.Minute)
-
- // Record another failure.
- // Before record: elapsed=1m, decay=1. Count becomes 1.
- // After record: Count becomes 2.
- service.RecordAttempt(ctx, key, false)
-
- entry, _ = repo.Get(ctx, key)
- assert.Equal(t, 2, entry.Count, "Count should be 2 (2 decayed to 1, then +1)")
-
- allowed, err = service.Allow(ctx, key)
- assert.True(t, allowed, "Should still be allowed")
-
- // 6. Test Success resets nothing but updates time (Leaky Bucket Standard)
- // Reset
- currentTime = time.Date(2023, 1, 1, 13, 0, 0, 0, time.UTC)
- repo = persistence.NewInMemoryRateLimitRepository()
- service = NewRateLimitService(repo, config)
- service.now = func() time.Time { return currentTime }
-
- service.RecordAttempt(ctx, key, false) // Count 1
- currentTime = currentTime.Add(30 * time.Second) // 0.5 decay
- service.RecordAttempt(ctx, key, true) // Success. Should update LastAttempt.
-
- entry, _ = repo.Get(ctx, key)
- assert.Equal(t, 1, entry.Count)
- assert.Equal(t, currentTime, entry.LastAttempt)
-
- currentTime = currentTime.Add(30 * time.Second) // Another 0.5 decay. Total 1 min since start.
- // But LastAttempt was updated at 30s. So elapsed is 30s. Decay = 0.
- // This is the "Leaky Bucket" behavior where consistent activity keeps it full?
- // Wait. If I update LastAttempt on success without reducing count, I am resetting the decay timer.
- // If I have 0.9 decay pending, and I succeed, I reset timer to 0 decay pending.
- // This penalizes frequent successful requests if they happen faster than decay rate?
- // No, because success doesn't add to count.
- // But it does delay the decay of existing failures.
- // If I fail once, then spam successes every second, the failure will never decay because elapsed < interval always.
- // This might be unintended.
- // FIX: We should accumulate partial decay or NOT update LastAttempt on success if we want purely time-based decay regardless of activity.
- // However, usually Rate Limiters *do* care about activity.
- // But for "Failure Rate Limiting", success shouldn't prevent failure decay.
- // Implementation choice:
- // A) Update LastAttempt on success: Active users keep their "failure score" longer. (Strict)
- // B) Don't update LastAttempt on success: Failures decay based on absolute time since last failure (or last check).
- // My implementation does (A).
-
- // Let's verify behavior A is what we have.
- service.RecordAttempt(ctx, key, false) // Count 1 + 0 (decay) = 2.
- // If behavior B (don't update on success), elapsed would be 30s from last failure check? No, LastAttempt was updated on success.
- // So we expect Count to be 2.
- // If we hadn't updated on success, elapsed would be 60s from first failure. Decay 1. Count would be 1.
-
- assert.Equal(t, 2, entry.Count + 1) // Logic check, wait.
-
- // Let's not assert on implementation detail of success-decay interaction unless specified.
- // I'll stick to asserting the "failures trigger block" and "time decays failures" basics.
-}
-
-func TestRateLimitService_Cleanup(t *testing.T) {
- repo := persistence.NewInMemoryRateLimitRepository()
- config := RateLimitConfig{
- MaxFailures: 3,
- BlockDuration: time.Minute,
- }
- service := NewRateLimitService(repo, config)
-
- // Mock time
- currentTime := time.Date(2023, 1, 1, 10, 0, 0, 0, time.UTC)
- service.now = func() time.Time { return currentTime }
-
- ctx := context.Background()
-
- // Add entry
- service.RecordAttempt(ctx, "key1", false)
-
- // Verify exists
- entry, _ := repo.Get(ctx, "key1")
- assert.NotNil(t, entry)
-
- // Advance time past expiration (Repo sets TTL = BlockDuration + buffer)
- // TTL logic: BlockDuration (1m) + 1m buffer = 2m.
- // Wait, code says `ttl += time.Minute`.
-
- // Manually invoke cleanup on repo?
- // Persistence layer relies on expiration check in Get() or manual Cleanup().
- // Let's test manual cleanup.
-
- // Add old entry directly to repo to test Cleanup logic
- oldTime := currentTime.Add(-24 * time.Hour)
- repo.Set(ctx, "old_key", &ports.RateLimitEntry{
- Key: "old_key",
- LastAttempt: oldTime,
- }, time.Hour)
-
- // We need to advance "real" time for `Get` to see it as expired?
- // `Get` uses `time.Now()`, not the service mocked time.
- // Ah, the repository implementation uses `time.Now()` directly!
- // My mock only affects the Service.
- // Testing expiration in `Get` relies on system time.
-
- // To test Cleanup properly, we should probably mock time in Repo too, or just test logic that doesn't rely on `time.Now()` inside Repo for this specific unit test,
- // OR just trust the logic I wrote:
- // `if now.After(item.expiresAt) { delete }`
-
- // Since I cannot mock time in the Repo (it uses `time.Now`), I will skip strict expiration tests that rely on waiting,
- // or assume `Cleanup` works as implemented.
-}
diff --git a/internal/infrastructure/logging/structured.go b/internal/infrastructure/logging/structured.go
index d81b2a06a2c681bda6a9f0608293e4dd5394b44f..aa6e09d98937323fccd1052b1e8e44d895bfa9d1 100644
--- a/internal/infrastructure/logging/structured.go
+++ b/internal/infrastructure/logging/structured.go
@@ -20,7 +20,7 @@ type contextKey string
const (
// CorrelationIDKey is the context key for correlation IDs
- CorrelationIDKey contextKey = "correlation_id"
+ CorrelationIDKey contextKey = "request_id"
// ServiceKey is the context key for service name
ServiceKey contextKey = "service_name"
// OperationKey is the context key for operation name
@@ -79,7 +79,7 @@ func (l *StructuredLogger) Configure(cfg *config.Config) error {
}
logPath := filepath.Join(logDir, "main.log")
-
+
if l.logWriter != nil {
_ = l.logWriter.Close()
}
@@ -232,6 +232,11 @@ func GetCorrelationID(ctx context.Context) string {
if ctx == nil {
return ""
}
+ // Try string key first (from Gin middleware)
+ if id, ok := ctx.Value("request_id").(string); ok {
+ return id
+ }
+ // Try typed key
if id, ok := ctx.Value(CorrelationIDKey).(string); ok {
return id
}
@@ -292,4 +297,4 @@ func GetLogger() *StructuredLogger {
// SetLogger sets the global structured logger instance
func SetLogger(logger *StructuredLogger) {
globalLogger = logger
-}
\ No newline at end of file
+}
diff --git a/internal/infrastructure/persistence/auth_repository.go b/internal/infrastructure/persistence/auth_repository.go
index b617800f5bb39e4a922d80d67d00b0b40c287431..64f553c2e0d6c9bd3dec167ea76c49a30f14b7e9 100644
--- a/internal/infrastructure/persistence/auth_repository.go
+++ b/internal/infrastructure/persistence/auth_repository.go
@@ -17,9 +17,9 @@ import (
// AuthRepository implements the ports.AuthRepository interface
type AuthRepository struct {
- authDir string
+ authDir string
authManager *coreauth.Manager
- mu sync.RWMutex
+ mu sync.RWMutex
}
// NewAuthRepository creates a new AuthRepository
@@ -207,7 +207,7 @@ func (r *AuthRepository) Delete(ctx context.Context, id string) error {
defer r.mu.Unlock()
fullPath := filepath.Join(r.authDir, id)
-
+
if !strings.HasSuffix(fullPath, ".json") {
fullPath += ".json"
}
@@ -260,7 +260,7 @@ func (r *AuthRepository) DeleteAll(ctx context.Context) (int, error) {
fullPath := filepath.Join(r.authDir, name)
if err := os.Remove(fullPath); err == nil {
deleted++
-
+
// Disable in auth manager
if r.authManager != nil {
if auth, ok := r.authManager.GetByID(name); ok {
@@ -407,17 +407,17 @@ func (r *AuthRepository) mapFileToAuth(file *ports.AuthFile) *coreauth.Auth {
}
return &coreauth.Auth{
- ID: file.ID,
- Provider: file.Provider,
- FileName: file.FileName,
- Label: file.Label,
- Status: coreauth.Status(file.Status),
- Disabled: file.Disabled,
+ ID: file.ID,
+ Provider: file.Provider,
+ FileName: file.FileName,
+ Label: file.Label,
+ Status: coreauth.Status(file.Status),
+ Disabled: file.Disabled,
Unavailable: file.Unavailable,
- Metadata: file.Metadata,
- Attributes: file.Attributes,
- CreatedAt: file.CreatedAt,
- UpdatedAt: file.UpdatedAt,
+ Metadata: file.Metadata,
+ Attributes: file.Attributes,
+ CreatedAt: file.CreatedAt,
+ UpdatedAt: file.UpdatedAt,
}
}
@@ -445,4 +445,4 @@ func (r *AuthRepository) SetAuthManager(manager *coreauth.Manager) {
}
// Ensure AuthRepository implements the interface
-var _ ports.AuthRepository = (*AuthRepository)(nil)
\ No newline at end of file
+var _ ports.AuthRepository = (*AuthRepository)(nil)
diff --git a/internal/infrastructure/persistence/config_repository.go b/internal/infrastructure/persistence/config_repository.go
index 2f066a2ac3cbf1121fb552d23cdaf3807e5c69b0..1fe238030b55689f2b2bcc81758582d2daad9b63 100644
--- a/internal/infrastructure/persistence/config_repository.go
+++ b/internal/infrastructure/persistence/config_repository.go
@@ -100,7 +100,7 @@ func (r *ConfigRepository) Validate(ctx context.Context, cfg *config.Config) err
return errors.Wrap(errors.InternalError, "failed to create temp file for validation", err)
}
tmpPath := tmpFile.Name()
-
+
// Cleanup
_ = tmpFile.Close()
defer os.Remove(tmpPath)
@@ -129,4 +129,4 @@ func (r *ConfigRepository) SetConfigPath(path string) {
}
// Ensure ConfigRepository implements the interface
-var _ ports.ConfigRepository = (*ConfigRepository)(nil)
\ No newline at end of file
+var _ ports.ConfigRepository = (*ConfigRepository)(nil)
diff --git a/internal/infrastructure/persistence/log_index.go b/internal/infrastructure/persistence/log_index.go
index 673eca4972e4fb5a83cb6cee659aa7a90f92c4f0..e9db5061ea79766e1e26738c065baea9bc5071b3 100644
--- a/internal/infrastructure/persistence/log_index.go
+++ b/internal/infrastructure/persistence/log_index.go
@@ -21,15 +21,15 @@ import (
// LogIndexEntry represents a single entry in the log index
type LogIndexEntry struct {
- RequestID string `json:"request_id"`
- Filename string `json:"filename"`
- Timestamp time.Time `json:"timestamp"`
- Method string `json:"method"`
- URL string `json:"url"`
- StatusCode int `json:"status_code"`
- Size int64 `json:"size"`
- Offset int64 `json:"offset"` // Byte offset in file for O(1) access
- Tags map[string]string `json:"tags"` // Optional tags for filtering
+ RequestID string `json:"request_id"`
+ Filename string `json:"filename"`
+ Timestamp time.Time `json:"timestamp"`
+ Method string `json:"method"`
+ URL string `json:"url"`
+ StatusCode int `json:"status_code"`
+ Size int64 `json:"size"`
+ Offset int64 `json:"offset"` // Byte offset in file for O(1) access
+ Tags map[string]string `json:"tags"` // Optional tags for filtering
}
// LogIndex provides O(1) lookup for log entries by various criteria
@@ -557,10 +557,10 @@ func (r *IndexedLogRepository) GetIndexStats() map[string]interface{} {
defer r.mu.RUnlock()
return map[string]interface{}{
- "total_entries": r.index.Size(),
- "is_dirty": r.index.IsDirty(),
- "index_path": r.indexPath,
- "last_persisted": r.index.lastPersisted,
+ "total_entries": r.index.Size(),
+ "is_dirty": r.index.IsDirty(),
+ "index_path": r.indexPath,
+ "last_persisted": r.index.lastPersisted,
}
}
diff --git a/internal/infrastructure/persistence/log_repository.go b/internal/infrastructure/persistence/log_repository.go
index 316a2141f27fcb79e22459bbef779fcf78f893d1..251fc70376030b7f59a393e01402129b008d5f14 100644
--- a/internal/infrastructure/persistence/log_repository.go
+++ b/internal/infrastructure/persistence/log_repository.go
@@ -377,15 +377,15 @@ func (r *LogRepository) DownloadRequestErrorLog(ctx context.Context, filename st
func (r *LogRepository) GetLogDirectory() string {
r.mu.RLock()
defer r.mu.RUnlock()
-
+
if r.logDir != "" {
return r.logDir
}
-
+
if r.cfg != nil {
return logging.ResolveLogDirectory(r.cfg)
}
-
+
return ""
}
@@ -393,7 +393,7 @@ func (r *LogRepository) GetLogDirectory() string {
func (r *LogRepository) IsLoggingEnabled() bool {
r.mu.RLock()
defer r.mu.RUnlock()
-
+
if r.cfg == nil {
return false
}
@@ -631,4 +631,4 @@ func parseTimestamp(line string) int64 {
}
// Ensure LogRepository implements the interface
-var _ ports.LogRepository = (*LogRepository)(nil)
\ No newline at end of file
+var _ ports.LogRepository = (*LogRepository)(nil)
diff --git a/internal/infrastructure/persistence/rate_limit_repository.go b/internal/infrastructure/persistence/rate_limit_repository.go
index dce2373783953faf50bfab7c8de1b58285ba5570..443f1c216ba797bb5c7dc3bc23bd61d07fac8197 100644
--- a/internal/infrastructure/persistence/rate_limit_repository.go
+++ b/internal/infrastructure/persistence/rate_limit_repository.go
@@ -82,7 +82,7 @@ func (r *InMemoryRateLimitRepository) Cleanup(ctx context.Context, olderThan tim
delete(r.store, key)
continue
}
-
+
// Also respect the explicit `olderThan` logic if needed, usually targeting LastAttempt
if item.entry.LastAttempt.Before(olderThan) {
delete(r.store, key)
diff --git a/internal/logging/gin_logger_test.go b/internal/logging/gin_logger_test.go
index 7de1833865e5f99936bc833f270eee1efb8e0c33..70b9dae11fb51cbc2f49eac0bfee6ed8782b2b72 100644
--- a/internal/logging/gin_logger_test.go
+++ b/internal/logging/gin_logger_test.go
@@ -18,7 +18,7 @@ func TestGinLogrusRecoveryRepanicsErrAbortHandler(t *testing.T) {
panic(http.ErrAbortHandler)
})
- req := httptest.NewRequest(http.MethodGet, "/abort", nil)
+ req := httptest.NewRequest(http.MethodGet, "/abort", http.NoBody)
recorder := httptest.NewRecorder()
defer func() {
@@ -50,7 +50,7 @@ func TestGinLogrusRecoveryHandlesRegularPanic(t *testing.T) {
panic("boom")
})
- req := httptest.NewRequest(http.MethodGet, "/panic", nil)
+ req := httptest.NewRequest(http.MethodGet, "/panic", http.NoBody)
recorder := httptest.NewRecorder()
engine.ServeHTTP(recorder, req)
diff --git a/internal/logging/request_logger.go b/internal/logging/request_logger.go
index 72dc26f34f5224bb2f8cf30e4ed1b1266f5d9129..9fe1240ec0aa2cac180427870f6fc8b4fa438abd 100644
--- a/internal/logging/request_logger.go
+++ b/internal/logging/request_logger.go
@@ -526,16 +526,16 @@ func writeRequestInfoWithBody(
if _, errWrite := io.WriteString(w, "=== REQUEST INFO ===\n"); errWrite != nil {
return errWrite
}
- if _, errWrite := io.WriteString(w, fmt.Sprintf("Version: %s\n", buildinfo.Version)); errWrite != nil {
+ if _, errWrite := fmt.Fprintf(w, "Version: %s\n", buildinfo.Version); errWrite != nil {
return errWrite
}
- if _, errWrite := io.WriteString(w, fmt.Sprintf("URL: %s\n", url)); errWrite != nil {
+ if _, errWrite := fmt.Fprintf(w, "URL: %s\n", url); errWrite != nil {
return errWrite
}
- if _, errWrite := io.WriteString(w, fmt.Sprintf("Method: %s\n", method)); errWrite != nil {
+ if _, errWrite := fmt.Fprintf(w, "Method: %s\n", method); errWrite != nil {
return errWrite
}
- if _, errWrite := io.WriteString(w, fmt.Sprintf("Timestamp: %s\n", timestamp.Format(time.RFC3339Nano))); errWrite != nil {
+ if _, errWrite := fmt.Fprintf(w, "Timestamp: %s\n", timestamp.Format(time.RFC3339Nano)); errWrite != nil {
return errWrite
}
if _, errWrite := io.WriteString(w, "\n"); errWrite != nil {
@@ -548,7 +548,7 @@ func writeRequestInfoWithBody(
for key, values := range headers {
for _, value := range values {
masked := util.MaskSensitiveHeaderValue(key, value)
- if _, errWrite := io.WriteString(w, fmt.Sprintf("%s: %s\n", key, masked)); errWrite != nil {
+ if _, errWrite := fmt.Fprintf(w, "%s: %s\n", key, masked); errWrite != nil {
return errWrite
}
}
@@ -623,7 +623,7 @@ func writeAPIErrorResponses(w io.Writer, apiResponseErrors []*interfaces.ErrorMe
if _, errWrite := io.WriteString(w, "=== API ERROR RESPONSE ===\n"); errWrite != nil {
return errWrite
}
- if _, errWrite := io.WriteString(w, fmt.Sprintf("HTTP Status: %d\n", apiResponseErrors[i].StatusCode)); errWrite != nil {
+ if _, errWrite := fmt.Fprintf(w, "HTTP Status: %d\n", apiResponseErrors[i].StatusCode); errWrite != nil {
return errWrite
}
if apiResponseErrors[i].Error != nil {
@@ -643,7 +643,7 @@ func writeResponseSection(w io.Writer, statusCode int, statusWritten bool, respo
return errWrite
}
if statusWritten {
- if _, errWrite := io.WriteString(w, fmt.Sprintf("Status: %d\n", statusCode)); errWrite != nil {
+ if _, errWrite := fmt.Fprintf(w, "Status: %d\n", statusCode); errWrite != nil {
return errWrite
}
}
@@ -651,7 +651,7 @@ func writeResponseSection(w io.Writer, statusCode int, statusWritten bool, respo
if responseHeaders != nil {
for key, values := range responseHeaders {
for _, value := range values {
- if _, errWrite := io.WriteString(w, fmt.Sprintf("%s: %s\n", key, value)); errWrite != nil {
+ if _, errWrite := fmt.Fprintf(w, "%s: %s\n", key, value); errWrite != nil {
return errWrite
}
}
@@ -668,7 +668,7 @@ func writeResponseSection(w io.Writer, statusCode int, statusWritten bool, respo
}
}
if decompressErr != nil {
- if _, errWrite := io.WriteString(w, fmt.Sprintf("\n[DECOMPRESSION ERROR: %v]", decompressErr)); errWrite != nil {
+ if _, errWrite := fmt.Fprintf(w, "\n[DECOMPRESSION ERROR: %v]", decompressErr); errWrite != nil {
return errWrite
}
}
@@ -773,7 +773,7 @@ func (l *FileRequestLogger) decompressResponse(responseHeaders map[string][]stri
// Check Content-Encoding header
var contentEncoding string
for key, values := range responseHeaders {
- if strings.ToLower(key) == "content-encoding" && len(values) > 0 {
+ if strings.EqualFold(key, "content-encoding") && len(values) > 0 {
contentEncoding = strings.ToLower(values[0])
break
}
diff --git a/internal/logging/request_logger_metrics.go b/internal/logging/request_logger_metrics.go
index 5f1ed53d25140b445a445cc273b73472c7618f3f..548ce2fd8fc1a3f0b6124af69cf6c358e86b8ac8 100644
--- a/internal/logging/request_logger_metrics.go
+++ b/internal/logging/request_logger_metrics.go
@@ -14,10 +14,10 @@ import (
// MetricsEnabledFileStreamingLogWriter wraps FileStreamingLogWriter with metrics collection
type MetricsEnabledFileStreamingLogWriter struct {
*FileStreamingLogWriter
- metrics ports.MetricsService
- dropCount atomic.Uint64
- startTime time.Time
- bytesWritten atomic.Int64
+ metrics ports.MetricsService
+ dropCount atomic.Uint64
+ startTime time.Time
+ bytesWritten atomic.Int64
}
// NewMetricsEnabledFileStreamingLogWriter creates a new metrics-enabled streaming log writer
diff --git a/internal/managementasset/management.html b/internal/managementasset/management.html
new file mode 100644
index 0000000000000000000000000000000000000000..a46c4230fae2b6f992d1d9e680e4b1b0486ac4f8
--- /dev/null
+++ b/internal/managementasset/management.html
@@ -0,0 +1,44 @@
+
+
+
+
+
+
+ CLI Proxy API Management Center
+
+
+
+
+
+
+
diff --git a/internal/managementasset/updater.go b/internal/managementasset/updater.go
index c941da024ae1e4c2df025b4a715d943cce68d949..29adec9a47e3312f9caa2d3e3306ded2c2bca49c 100644
--- a/internal/managementasset/updater.go
+++ b/internal/managementasset/updater.go
@@ -333,7 +333,7 @@ func fetchLatestAsset(ctx context.Context, client *http.Client, releaseURL strin
releaseURL = defaultManagementReleaseURL
}
- req, err := http.NewRequestWithContext(ctx, http.MethodGet, releaseURL, nil)
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, releaseURL, http.NoBody)
if err != nil {
return nil, "", fmt.Errorf("create release request: %w", err)
}
@@ -378,7 +378,7 @@ func downloadAsset(ctx context.Context, client *http.Client, downloadURL string)
return nil, "", fmt.Errorf("empty download url")
}
- req, err := http.NewRequestWithContext(ctx, http.MethodGet, downloadURL, nil)
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, downloadURL, http.NoBody)
if err != nil {
return nil, "", fmt.Errorf("create download request: %w", err)
}
diff --git a/internal/runtime/executor/claude_executor.go b/internal/runtime/executor/claude_executor.go
index 170ebb9029fa2481ae8a47a4483e7ab343d90e90..84001e35de3cde1475f82a72111412bdf163bcef 100644
--- a/internal/runtime/executor/claude_executor.go
+++ b/internal/runtime/executor/claude_executor.go
@@ -851,7 +851,7 @@ func getCloakConfigFromAuth(auth *cliproxyauth.Auth) (string, bool, []string) {
cloakMode = "auto"
}
- strictMode := strings.ToLower(auth.Attributes["cloak_strict_mode"]) == "true"
+ strictMode := strings.EqualFold(auth.Attributes["cloak_strict_mode"], "true")
var sensitiveWords []string
if wordsStr := auth.Attributes["cloak_sensitive_words"]; wordsStr != "" {
diff --git a/internal/runtime/executor/gemini_executor.go b/internal/runtime/executor/gemini_executor.go
index 58bd71a2155830674a338ff132c4c6054ae0d0d3..aa3a53fe7bfbe98a1186ef8352583b069c01fadf 100644
--- a/internal/runtime/executor/gemini_executor.go
+++ b/internal/runtime/executor/gemini_executor.go
@@ -136,7 +136,7 @@ func (e *GeminiExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, r
action = "countTokens"
}
}
- baseURL := resolveGeminiBaseURL(auth)
+ baseURL := e.resolveGeminiBaseURL(auth)
url := fmt.Sprintf("%s/%s/models/%s:%s", baseURL, glAPIVersion, baseModel, action)
if opts.Alt != "" && action != "countTokens" {
url = url + fmt.Sprintf("?$alt=%s", opts.Alt)
@@ -233,7 +233,7 @@ func (e *GeminiExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A
body = applyPayloadConfigWithRoot(e.cfg, baseModel, to.String(), "", body, originalTranslated, requestedModel)
body, _ = sjson.SetBytes(body, "model", baseModel)
- baseURL := resolveGeminiBaseURL(auth)
+ baseURL := e.resolveGeminiBaseURL(auth)
url := fmt.Sprintf("%s/%s/models/%s:%s", baseURL, glAPIVersion, baseModel, "streamGenerateContent")
if opts.Alt == "" {
url = url + "?alt=sse"
@@ -352,7 +352,7 @@ func (e *GeminiExecutor) CountTokens(ctx context.Context, auth *cliproxyauth.Aut
translatedReq, _ = sjson.DeleteBytes(translatedReq, "safetySettings")
translatedReq, _ = sjson.SetBytes(translatedReq, "model", baseModel)
- baseURL := resolveGeminiBaseURL(auth)
+ baseURL := e.resolveGeminiBaseURL(auth)
url := fmt.Sprintf("%s/%s/models/%s:%s", baseURL, glAPIVersion, baseModel, "countTokens")
requestBody := bytes.NewReader(translatedReq)
@@ -439,13 +439,26 @@ func geminiCreds(a *cliproxyauth.Auth) (apiKey, bearer string) {
return
}
-func resolveGeminiBaseURL(auth *cliproxyauth.Auth) string {
+func (e *GeminiExecutor) resolveGeminiBaseURL(auth *cliproxyauth.Auth) string {
base := glEndpoint
+ // 1. Check auth attributes (dynamic override)
if auth != nil && auth.Attributes != nil {
if custom := strings.TrimSpace(auth.Attributes["base_url"]); custom != "" {
- base = strings.TrimRight(custom, "/")
+ return strings.TrimRight(custom, "/")
}
}
+
+ // 2. Check config (static override)
+ if e.cfg != nil {
+ // Try to match specific key config if possible
+ if keyConfig := e.resolveGeminiConfig(auth); keyConfig != nil && keyConfig.BaseURL != "" {
+ return strings.TrimRight(keyConfig.BaseURL, "/")
+ }
+
+ // Fallback: check if ANY configured key has a base URL (if simple single-key setup)
+ // Or if there is a global override (not currently in Config struct but good practice to check)
+ }
+
if base == "" {
return glEndpoint
}
diff --git a/internal/runtime/executor/iflow_executor_test.go b/internal/runtime/executor/iflow_executor_test.go
index e588548b0f9736612ffc80e0263e2ec9770dcb36..c0d89eee3a75436970759140031e3c8b412a8583 100644
--- a/internal/runtime/executor/iflow_executor_test.go
+++ b/internal/runtime/executor/iflow_executor_test.go
@@ -1,6 +1,7 @@
package executor
import (
+ "bytes"
"testing"
"github.com/router-for-me/CLIProxyAPI/v6/internal/thinking"
@@ -59,7 +60,7 @@ func TestPreserveReasoningContentInMessages(t *testing.T) {
if want == nil {
want = tt.input
}
- if string(got) != string(want) {
+ if !bytes.Equal(got, want) {
t.Errorf("preserveReasoningContentInMessages() = %s, want %s", got, want)
}
})
diff --git a/internal/runtime/executor/kiro_executor.go b/internal/runtime/executor/kiro_executor.go
index cb6ec2193e16337c60c2f1d76b2c3fceb704dede..eb1f7bc9fe06975602486b362f676276b1286eca 100644
--- a/internal/runtime/executor/kiro_executor.go
+++ b/internal/runtime/executor/kiro_executor.go
@@ -84,13 +84,13 @@ func kiroCreds(a *cliproxyauth.Auth) (accessToken, refreshToken, region, profile
// Kiro model name mappings (display name -> internal Kiro ID)
var kiroModelMappings = map[string]string{
- "claude-sonnet-4": "CLAUDE_SONNET_4_V1_0",
- "claude-sonnet-4.5": "CLAUDE_SONNET_4_5_V1_0",
- "claude-haiku-4.5": "CLAUDE_HAIKU_4_5_V1_0",
- "claude-opus-4.5": "CLAUDE_OPUS_4_5_V1_0",
- "claude-3.7-sonnet": "CLAUDE_3_7_SONNET_20250219_V1_0",
- "claude-3-7-sonnet": "CLAUDE_3_7_SONNET_20250219_V1_0",
- "auto": "auto",
+ "claude-sonnet-4": "CLAUDE_SONNET_4_V1_0",
+ "claude-sonnet-4.5": "CLAUDE_SONNET_4_5_V1_0",
+ "claude-haiku-4.5": "CLAUDE_HAIKU_4_5_V1_0",
+ "claude-opus-4.5": "CLAUDE_OPUS_4_5_V1_0",
+ "claude-3.7-sonnet": "CLAUDE_3_7_SONNET_20250219_V1_0",
+ "claude-3-7-sonnet": "CLAUDE_3_7_SONNET_20250219_V1_0",
+ "auto": "auto",
}
// normalizeKiroModel normalizes model names for the Kiro API.
@@ -182,9 +182,9 @@ func buildKiroPayload(body []byte, model string, profileARN string) ([]byte, err
// Build Kiro request payload
kiroPayload := map[string]any{
- "conversationState": conversationState,
+ "conversationState": conversationState,
"additionalInstructions": systemPrompt,
- "profileArn": profileARN,
+ "profileArn": profileARN,
}
// Add model selection if not auto
@@ -606,10 +606,10 @@ func convertKiroToClaudeResponse(data []byte, model string) []byte {
// Build Claude-format response
claudeResp := map[string]any{
- "id": fmt.Sprintf("msg_%d", time.Now().UnixNano()),
- "type": "message",
- "role": "assistant",
- "model": model,
+ "id": fmt.Sprintf("msg_%d", time.Now().UnixNano()),
+ "type": "message",
+ "role": "assistant",
+ "model": model,
"content": []map[string]any{
{
"type": "text",
@@ -659,7 +659,7 @@ func parseKiroStreamLine(line []byte, model string, contentBuilder *strings.Buil
// Build Claude streaming format
event := map[string]any{
- "type": "content_block_delta",
+ "type": "content_block_delta",
"index": 0,
"delta": map[string]any{
"type": "text_delta",
diff --git a/internal/runtime/executor/logging_helpers.go b/internal/runtime/executor/logging_helpers.go
index e9876243355f84d8ef787d7ef25acd0c6d31a78e..853cc50574dc4df975b12f787d70b3f0aa0a23e9 100644
--- a/internal/runtime/executor/logging_helpers.go
+++ b/internal/runtime/executor/logging_helpers.go
@@ -63,18 +63,18 @@ func recordAPIRequest(ctx context.Context, cfg *config.Config, info upstreamRequ
index := len(attempts) + 1
builder := &strings.Builder{}
- builder.WriteString(fmt.Sprintf("=== API REQUEST %d ===\n", index))
- builder.WriteString(fmt.Sprintf("Timestamp: %s\n", time.Now().Format(time.RFC3339Nano)))
+ fmt.Fprintf(builder, "=== API REQUEST %d ===\n", index)
+ fmt.Fprintf(builder, "Timestamp: %s\n", time.Now().Format(time.RFC3339Nano))
if info.URL != "" {
- builder.WriteString(fmt.Sprintf("Upstream URL: %s\n", info.URL))
+ fmt.Fprintf(builder, "Upstream URL: %s\n", info.URL)
} else {
builder.WriteString("Upstream URL: \n")
}
if info.Method != "" {
- builder.WriteString(fmt.Sprintf("HTTP Method: %s\n", info.Method))
+ fmt.Fprintf(builder, "HTTP Method: %s\n", info.Method)
}
if auth := formatAuthInfo(info); auth != "" {
- builder.WriteString(fmt.Sprintf("Auth: %s\n", auth))
+ fmt.Fprintf(builder, "Auth: %s\n", auth)
}
builder.WriteString("\nHeaders:\n")
writeHeaders(builder, info.Headers)
@@ -109,7 +109,7 @@ func recordAPIResponseMetadata(ctx context.Context, cfg *config.Config, status i
ensureResponseIntro(attempt)
if status > 0 && !attempt.statusWritten {
- attempt.response.WriteString(fmt.Sprintf("Status: %d\n", status))
+ fmt.Fprintf(attempt.response, "Status: %d\n", status)
attempt.statusWritten = true
}
if !attempt.headersWritten {
@@ -141,7 +141,7 @@ func recordAPIResponseError(ctx context.Context, cfg *config.Config, err error)
if attempt.errorWritten {
attempt.response.WriteString("\n")
}
- attempt.response.WriteString(fmt.Sprintf("Error: %s\n", err.Error()))
+ fmt.Fprintf(attempt.response, "Error: %s\n", err.Error())
attempt.errorWritten = true
updateAggregatedResponse(ginCtx, attempts)
@@ -218,8 +218,8 @@ func ensureResponseIntro(attempt *upstreamAttempt) {
if attempt == nil || attempt.response == nil || attempt.responseIntroWritten {
return
}
- attempt.response.WriteString(fmt.Sprintf("=== API RESPONSE %d ===\n", attempt.index))
- attempt.response.WriteString(fmt.Sprintf("Timestamp: %s\n", time.Now().Format(time.RFC3339Nano)))
+ fmt.Fprintf(attempt.response, "=== API RESPONSE %d ===\n", attempt.index)
+ fmt.Fprintf(attempt.response, "Timestamp: %s\n", time.Now().Format(time.RFC3339Nano))
attempt.response.WriteString("\n")
attempt.responseIntroWritten = true
}
@@ -275,12 +275,12 @@ func writeHeaders(builder *strings.Builder, headers http.Header) {
for _, key := range keys {
values := headers[key]
if len(values) == 0 {
- builder.WriteString(fmt.Sprintf("%s:\n", key))
+ fmt.Fprintf(builder, "%s:\n", key)
continue
}
for _, value := range values {
masked := util.MaskSensitiveHeaderValue(key, value)
- builder.WriteString(fmt.Sprintf("%s: %s\n", key, masked))
+ fmt.Fprintf(builder, "%s: %s\n", key, masked)
}
}
}
diff --git a/internal/runtime/executor/openai_compat_executor.go b/internal/runtime/executor/openai_compat_executor.go
index 85df21b1d28f77f51ea724a73bce492877126e19..12f95b39da7839d5ed9467b8a375d7285447e2aa 100644
--- a/internal/runtime/executor/openai_compat_executor.go
+++ b/internal/runtime/executor/openai_compat_executor.go
@@ -78,7 +78,7 @@ func (e *OpenAICompatExecutor) Execute(ctx context.Context, auth *cliproxyauth.A
baseURL, apiKey := e.resolveCredentials(auth)
if baseURL == "" {
err = statusErr{code: http.StatusUnauthorized, msg: "missing provider baseURL"}
- return
+ return resp, err
}
// Translate inbound request to OpenAI format
diff --git a/internal/translator/antigravity/claude/antigravity_claude_request.go b/internal/translator/antigravity/claude/antigravity_claude_request.go
index e87a7d6b6d1d90410acb5fd91b8525cb9a74fe0b..579b00965587cc3589f8e641345e132744eb8d3d 100644
--- a/internal/translator/antigravity/claude/antigravity_claude_request.go
+++ b/internal/translator/antigravity/claude/antigravity_claude_request.go
@@ -226,7 +226,6 @@ func ConvertClaudeRequestToAntigravity(modelName string, inputRawJSON []byte, _
} else {
functionResponseJSON, _ = sjson.SetRaw(functionResponseJSON, "response.result", functionResponseResult.Raw)
}
-
} else if functionResponseResult.IsObject() {
functionResponseJSON, _ = sjson.SetRaw(functionResponseJSON, "response.result", functionResponseResult.Raw)
} else {
diff --git a/internal/translator/claude/openai/chat-completions/claude_openai_request.go b/internal/translator/claude/openai/chat-completions/claude_openai_request.go
index 41274628a12ab326125d6b81ef6681bd83b9ed35..b8746f5f70b32056de242336d4404d90d5134a57 100644
--- a/internal/translator/claude/openai/chat-completions/claude_openai_request.go
+++ b/internal/translator/claude/openai/chat-completions/claude_openai_request.go
@@ -168,6 +168,16 @@ func ConvertOpenAIRequestToClaude(modelName string, inputRawJSON []byte, stream
msg := `{"role":"","content":[]}`
msg, _ = sjson.Set(msg, "role", role)
+ // Handle reasoning_content for assistant messages - convert to thinking block
+ // This must come BEFORE tool_calls in the content array per Claude API requirements
+ if role == "assistant" {
+ if reasoningContent := message.Get("reasoning_content"); reasoningContent.Exists() && reasoningContent.String() != "" {
+ thinkingPart := `{"type":"thinking","thinking":""}`
+ thinkingPart, _ = sjson.Set(thinkingPart, "thinking", reasoningContent.String())
+ msg, _ = sjson.SetRaw(msg, "content.-1", thinkingPart)
+ }
+ }
+
// Handle content based on its type (string or array)
if contentResult.Exists() && contentResult.Type == gjson.String && contentResult.String() != "" {
part := `{"type":"text","text":""}`
diff --git a/internal/translator/claude/openai/chat-completions/claude_openai_request_test.go b/internal/translator/claude/openai/chat-completions/claude_openai_request_test.go
new file mode 100644
index 0000000000000000000000000000000000000000..38635bcbe433c4222a6b0e5f75d17f34d3e31274
--- /dev/null
+++ b/internal/translator/claude/openai/chat-completions/claude_openai_request_test.go
@@ -0,0 +1,139 @@
+package chat_completions
+
+import (
+ "testing"
+
+ "github.com/tidwall/gjson"
+)
+
+func TestConvertOpenAIRequestToClaude_ReasoningContent(t *testing.T) {
+ // Test case: Assistant message with reasoning_content and tool_calls
+ // This is the scenario causing the error "thinking is enabled but reasoning_content is missing"
+ input := []byte(`{
+ "model": "claude-3-7-sonnet-20250219",
+ "messages": [
+ {"role": "user", "content": "What is 2+2?"},
+ {
+ "role": "assistant",
+ "content": "Let me calculate that for you.",
+ "reasoning_content": "The user is asking for a simple arithmetic calculation. 2+2=4.",
+ "tool_calls": [
+ {
+ "id": "call_123",
+ "type": "function",
+ "function": {
+ "name": "calculator",
+ "arguments": "{\"a\": 2, \"b\": 2}"
+ }
+ }
+ ]
+ }
+ ]
+ }`)
+
+ result := ConvertOpenAIRequestToClaude("claude-3-7-sonnet-20250219", input, false)
+
+ // Parse result to verify structure
+ root := gjson.ParseBytes(result)
+
+ // Verify the assistant message has thinking block before tool_use
+ messages := root.Get("messages").Array()
+ if len(messages) < 2 {
+ t.Fatal("Expected at least 2 messages")
+ }
+
+ assistantMsg := messages[1]
+ content := assistantMsg.Get("content").Array()
+
+ if len(content) < 3 {
+ t.Fatalf("Expected at least 3 content blocks (thinking + text + tool_use), got %d", len(content))
+ }
+
+ // First block should be thinking
+ if content[0].Get("type").String() != "thinking" {
+ t.Errorf("First content block should be 'thinking', got '%s'", content[0].Get("type").String())
+ }
+
+ thinkingText := content[0].Get("thinking").String()
+ expectedThinking := "The user is asking for a simple arithmetic calculation. 2+2=4."
+ if thinkingText != expectedThinking {
+ t.Errorf("Thinking text mismatch.\nExpected: %s\nGot: %s", expectedThinking, thinkingText)
+ }
+
+ // Second block should be text
+ if content[1].Get("type").String() != "text" {
+ t.Errorf("Second content block should be 'text', got '%s'", content[1].Get("type").String())
+ }
+
+ // Third block should be tool_use
+ if content[2].Get("type").String() != "tool_use" {
+ t.Errorf("Third content block should be 'tool_use', got '%s'", content[2].Get("type").String())
+ }
+}
+
+func TestConvertOpenAIRequestToClaude_ReasoningContentOnly(t *testing.T) {
+ // Test case: Assistant message with only reasoning_content (no text content)
+ input := []byte(`{
+ "model": "claude-3-7-sonnet-20250219",
+ "messages": [
+ {"role": "user", "content": "What is 2+2?"},
+ {
+ "role": "assistant",
+ "content": "",
+ "reasoning_content": "The user is asking for a simple arithmetic calculation. 2+2=4."
+ }
+ ]
+ }`)
+
+ result := ConvertOpenAIRequestToClaude("claude-3-7-sonnet-20250219", input, false)
+
+ root := gjson.ParseBytes(result)
+ messages := root.Get("messages").Array()
+ if len(messages) < 2 {
+ t.Fatal("Expected at least 2 messages")
+ }
+
+ assistantMsg := messages[1]
+ content := assistantMsg.Get("content").Array()
+
+ if len(content) != 1 {
+ t.Fatalf("Expected 1 content block (thinking), got %d", len(content))
+ }
+
+ if content[0].Get("type").String() != "thinking" {
+ t.Errorf("Content block should be 'thinking', got '%s'", content[0].Get("type").String())
+ }
+}
+
+func TestConvertOpenAIRequestToClaude_NoReasoningContent(t *testing.T) {
+ // Test case: Assistant message without reasoning_content (should work as before)
+ input := []byte(`{
+ "model": "claude-3-7-sonnet-20250219",
+ "messages": [
+ {"role": "user", "content": "What is 2+2?"},
+ {
+ "role": "assistant",
+ "content": "The answer is 4."
+ }
+ ]
+ }`)
+
+ result := ConvertOpenAIRequestToClaude("claude-3-7-sonnet-20250219", input, false)
+
+ root := gjson.ParseBytes(result)
+ messages := root.Get("messages").Array()
+ if len(messages) < 2 {
+ t.Fatal("Expected at least 2 messages")
+ }
+
+ assistantMsg := messages[1]
+ content := assistantMsg.Get("content").Array()
+
+ if len(content) != 1 {
+ t.Fatalf("Expected 1 content block (text), got %d", len(content))
+ }
+
+ if content[0].Get("type").String() != "text" {
+ t.Errorf("Content block should be 'text', got '%s'", content[0].Get("type").String())
+ }
+}
diff --git a/internal/translator/claude/openai/responses/claude_openai-responses_request.go b/internal/translator/claude/openai/responses/claude_openai-responses_request.go
index 5cbe23bf1b989ffa1da508637d5da9ff125e6048..96617bde374893e6b2a793a04747b76df8508d13 100644
--- a/internal/translator/claude/openai/responses/claude_openai-responses_request.go
+++ b/internal/translator/claude/openai/responses/claude_openai-responses_request.go
@@ -332,7 +332,6 @@ func ConvertOpenAIResponsesRequestToClaude(modelName string, inputRawJSON []byte
out, _ = sjson.SetRaw(out, "tool_choice", toolChoiceJSON)
}
default:
-
}
}
diff --git a/internal/translator/codex/claude/codex_claude_request.go b/internal/translator/codex/claude/codex_claude_request.go
index f0f5d867eae9c65e71bf0492efdb11f89a2ceb0d..a54b7b25cb5360de84f94563b22c6f1be06eea8b 100644
--- a/internal/translator/codex/claude/codex_claude_request.go
+++ b/internal/translator/codex/claude/codex_claude_request.go
@@ -168,7 +168,6 @@ func ConvertClaudeRequestToCodex(modelName string, inputRawJSON []byte, _ bool)
flushMessage()
}
}
-
}
// Convert tools declarations to the expected format for the Codex API.
diff --git a/internal/translator/codex/claude/codex_claude_response.go b/internal/translator/codex/claude/codex_claude_response.go
index 5223cd94d014e2f0ddd530bd14a8c089b9af3a7d..bfd92053f71f845520f83899d5fa4bd371a832c5 100644
--- a/internal/translator/codex/claude/codex_claude_response.go
+++ b/internal/translator/codex/claude/codex_claude_response.go
@@ -88,7 +88,6 @@ func ConvertCodexResponseToClaude(_ context.Context, _ string, originalRequestRa
output = "event: content_block_stop\n"
output += fmt.Sprintf("data: %s\n\n", template)
-
} else if typeStr == "response.content_part.added" {
template = `{"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}`
template, _ = sjson.Set(template, "index", (*param).(*ConvertCodexResponseToClaudeParams).BlockIndex)
diff --git a/internal/translator/codex/gemini/codex_gemini_response.go b/internal/translator/codex/gemini/codex_gemini_response.go
index 82a2187fe61a23d76155b0d7472f91bacac612a6..f882b550e1ce98e4d50fbb452fbcdba246bf3d9e 100644
--- a/internal/translator/codex/gemini/codex_gemini_response.go
+++ b/internal/translator/codex/gemini/codex_gemini_response.go
@@ -134,7 +134,6 @@ func ConvertCodexResponseToGemini(_ context.Context, modelName string, originalR
} else {
return []string{template}
}
-
}
// ConvertCodexResponseToGeminiNonStream converts a non-streaming Codex response to a non-streaming Gemini response.
diff --git a/internal/translator/codex/openai/chat-completions/codex_openai_response.go b/internal/translator/codex/openai/chat-completions/codex_openai_response.go
index 6d86c247a8425401bc9272ab43bc5a6596b14952..7d4abcf48c0d83c0207e10e1a22fe25f4af8336a 100644
--- a/internal/translator/codex/openai/chat-completions/codex_openai_response.go
+++ b/internal/translator/codex/openai/chat-completions/codex_openai_response.go
@@ -143,7 +143,6 @@ func ConvertCodexResponseToOpenAI(_ context.Context, modelName string, originalR
template, _ = sjson.Set(template, "choices.0.delta.role", "assistant")
template, _ = sjson.SetRaw(template, "choices.0.delta.tool_calls.-1", functionCallItemTemplate)
}
-
} else {
return []string{}
}
diff --git a/internal/translator/gemini/openai/chat-completions/gemini_openai_request.go b/internal/translator/gemini/openai/chat-completions/gemini_openai_request.go
index 0a35cfd0c3c6f90e616f4bf030e28adf45acc380..87c5ba4a99c4646a8666e49e9f2a79c8061851b7 100644
--- a/internal/translator/gemini/openai/chat-completions/gemini_openai_request.go
+++ b/internal/translator/gemini/openai/chat-completions/gemini_openai_request.go
@@ -36,18 +36,49 @@ func ConvertOpenAIRequestToGemini(modelName string, inputRawJSON []byte, _ bool)
out, _ = sjson.SetBytes(out, "model", modelName)
// Apply thinking configuration: convert OpenAI reasoning_effort to Gemini thinkingConfig.
- // Inline translation-only mapping; capability checks happen later in ApplyThinking.
+ // We use the standard v1beta fields: includeThoughts (bool) and thinkingBudgetTokenCount (int).
re := gjson.GetBytes(rawJSON, "reasoning_effort")
if re.Exists() {
effort := strings.ToLower(strings.TrimSpace(re.String()))
if effort != "" {
thinkingPath := "generationConfig.thinkingConfig"
if effort == "auto" {
- out, _ = sjson.SetBytes(out, thinkingPath+".thinkingBudget", -1)
+ // For "auto", we enable thoughts and set a reasonable default or let the API decide (usually implied).
+ // We don't set a budget to let the model decide, just enable thoughts.
out, _ = sjson.SetBytes(out, thinkingPath+".includeThoughts", true)
} else {
- out, _ = sjson.SetBytes(out, thinkingPath+".thinkingLevel", effort)
- out, _ = sjson.SetBytes(out, thinkingPath+".includeThoughts", effort != "none")
+ // Map levels to budget (approximations as Gemini uses raw token counts)
+ // Low: 1024, Medium: 4096, High: 8192 (examples)
+ var budget int
+ switch effort {
+ case "low":
+ budget = 2048
+ case "medium":
+ budget = 8192 // Standard Gemini thinking default often higher
+ case "high":
+ budget = 32768 // Extended thinking
+ default:
+ budget = 0 // None/Unknown
+ }
+
+ if effort != "none" {
+ out, _ = sjson.SetBytes(out, thinkingPath+".includeThoughts", true)
+ if budget > 0 {
+ out, _ = sjson.SetBytes(out, thinkingPath+".thinkingBudgetTokenCount", budget)
+ }
+ } else {
+ out, _ = sjson.SetBytes(out, thinkingPath+".includeThoughts", false)
+ }
+ }
+ }
+ } else {
+ // Pass through raw thinking_config if provided directly in the request (e.g. via extra_body)
+ if tc := gjson.GetBytes(rawJSON, "thinking_config"); tc.Exists() {
+ if include := tc.Get("include_thoughts"); include.Exists() {
+ out, _ = sjson.SetBytes(out, "generationConfig.thinkingConfig.includeThoughts", include.Bool())
+ }
+ if budget := tc.Get("thinking_budget_token_count"); budget.Exists() {
+ out, _ = sjson.SetBytes(out, "generationConfig.thinkingConfig.thinkingBudgetTokenCount", budget.Int())
}
}
}
@@ -289,47 +320,44 @@ func ConvertOpenAIRequestToGemini(modelName string, inputRawJSON []byte, _ bool)
}
}
- // tools -> tools[].functionDeclarations + tools[].googleSearch passthrough
+ // tools -> tools[].functionDeclarations + tools[].googleSearch + tools[].codeExecution
tools := gjson.GetBytes(rawJSON, "tools")
if tools.IsArray() && len(tools.Array()) > 0 {
functionToolNode := []byte(`{}`)
hasFunction := false
+ hasCodeExecution := false
googleSearchNodes := make([][]byte, 0)
+
for _, t := range tools.Array() {
- if t.Get("type").String() == "function" {
+ tType := t.Get("type").String()
+ if tType == "function" {
fn := t.Get("function")
if fn.Exists() && fn.IsObject() {
fnRaw := fn.Raw
+ // ... existing function processing ...
if fn.Get("parameters").Exists() {
renamed, errRename := util.RenameKey(fnRaw, "parameters", "parametersJsonSchema")
if errRename != nil {
log.Warnf("Failed to rename parameters for tool '%s': %v", fn.Get("name").String(), errRename)
+ // Fallback to manual fix if util fails (though util.RenameKey is robust)
+ // Just use raw parameters and hope sjson sets it right or log error
var errSet error
fnRaw, errSet = sjson.Set(fnRaw, "parametersJsonSchema.type", "object")
if errSet != nil {
- log.Warnf("Failed to set default schema type for tool '%s': %v", fn.Get("name").String(), errSet)
- continue
- }
- fnRaw, errSet = sjson.SetRaw(fnRaw, "parametersJsonSchema.properties", `{}`)
- if errSet != nil {
- log.Warnf("Failed to set default schema properties for tool '%s': %v", fn.Get("name").String(), errSet)
continue
}
+ fnRaw, _ = sjson.SetRaw(fnRaw, "parametersJsonSchema.properties", `{}`)
} else {
fnRaw = renamed
}
} else {
+ // Ensure parametersJsonSchema exists even if empty
var errSet error
fnRaw, errSet = sjson.Set(fnRaw, "parametersJsonSchema.type", "object")
if errSet != nil {
- log.Warnf("Failed to set default schema type for tool '%s': %v", fn.Get("name").String(), errSet)
- continue
- }
- fnRaw, errSet = sjson.SetRaw(fnRaw, "parametersJsonSchema.properties", `{}`)
- if errSet != nil {
- log.Warnf("Failed to set default schema properties for tool '%s': %v", fn.Get("name").String(), errSet)
continue
}
+ fnRaw, _ = sjson.SetRaw(fnRaw, "parametersJsonSchema.properties", `{}`)
}
fnRaw, _ = sjson.Delete(fnRaw, "strict")
if !hasFunction {
@@ -343,7 +371,10 @@ func ConvertOpenAIRequestToGemini(modelName string, inputRawJSON []byte, _ bool)
functionToolNode = tmp
hasFunction = true
}
+ } else if tType == "code_interpreter" {
+ hasCodeExecution = true
}
+
if gs := t.Get("google_search"); gs.Exists() {
googleToolNode := []byte(`{}`)
var errSet error
@@ -355,11 +386,16 @@ func ConvertOpenAIRequestToGemini(modelName string, inputRawJSON []byte, _ bool)
googleSearchNodes = append(googleSearchNodes, googleToolNode)
}
}
- if hasFunction || len(googleSearchNodes) > 0 {
+
+ if hasFunction || len(googleSearchNodes) > 0 || hasCodeExecution {
toolsNode := []byte("[]")
if hasFunction {
toolsNode, _ = sjson.SetRawBytes(toolsNode, "-1", functionToolNode)
}
+ if hasCodeExecution {
+ // Gemini Code Execution tool
+ toolsNode, _ = sjson.SetRawBytes(toolsNode, "-1", []byte(`{"codeExecution":{}}`))
+ }
for _, googleNode := range googleSearchNodes {
toolsNode, _ = sjson.SetRawBytes(toolsNode, "-1", googleNode)
}
@@ -367,6 +403,39 @@ func ConvertOpenAIRequestToGemini(modelName string, inputRawJSON []byte, _ bool)
}
}
+ // Tool Choice (function calling config)
+ // Map OpenAI tool_choice to Gemini toolConfig
+ toolChoice := gjson.GetBytes(rawJSON, "tool_choice")
+ if toolChoice.Exists() {
+ mode := "AUTO" // Default
+ var allowedNames []string
+
+ if toolChoice.Type == gjson.String {
+ tcStr := toolChoice.String()
+ if tcStr == "none" {
+ mode = "NONE"
+ } else if tcStr == "auto" {
+ mode = "AUTO"
+ } else if tcStr == "required" {
+ mode = "ANY"
+ }
+ } else if toolChoice.IsObject() {
+ // Specific function: { "type": "function", "function": { "name": "my_func" } }
+ if toolChoice.Get("type").String() == "function" {
+ mode = "ANY"
+ name := toolChoice.Get("function.name").String()
+ if name != "" {
+ allowedNames = append(allowedNames, name)
+ }
+ }
+ }
+
+ out, _ = sjson.SetBytes(out, "toolConfig.functionCallingConfig.mode", mode)
+ if len(allowedNames) > 0 {
+ out, _ = sjson.SetBytes(out, "toolConfig.functionCallingConfig.allowedFunctionNames", allowedNames)
+ }
+ }
+
out = common.AttachDefaultSafetySettings(out, "safetySettings")
return out
diff --git a/internal/translator/gemini/openai/chat-completions/gemini_openai_response.go b/internal/translator/gemini/openai/chat-completions/gemini_openai_response.go
index 9cce35f9759b577ee78c026ab69596d8556ad0b5..cce589b49d81d55a2292568e6a3b7091ca921141 100644
--- a/internal/translator/gemini/openai/chat-completions/gemini_openai_response.go
+++ b/internal/translator/gemini/openai/chat-completions/gemini_openai_response.go
@@ -136,34 +136,58 @@ func ConvertGeminiResponseToOpenAI(_ context.Context, _ string, originalRequestR
}
partsResult := candidate.Get("content.parts")
+ groundingMetadata := candidate.Get("groundingMetadata")
hasFunctionCall := false
+ // Handle Grounding (Google Search)
+ // If we have search entry point (HTML) or web search queries, we should display them.
+ // Ideally, we append this to the content so the user sees it.
+ if groundingMetadata.Exists() {
+ var groundingText strings.Builder
+
+ // 1. Search Entry Point (rendered HTML usually)
+ if rendered := groundingMetadata.Get("searchEntryPoint.renderedContent"); rendered.Exists() && rendered.String() != "" {
+ groundingText.WriteString("\n\n" + rendered.String())
+ }
+
+ // 2. Web Search Queries (if available) - optional, maybe verbose
+ // queries := groundingMetadata.Get("webSearchQueries")
+
+ // Append to content if we have grounding info
+ if groundingText.Len() > 0 {
+ // We need to find the text part to append to, or create one.
+ // If streaming, this might come in a separate chunk or the last chunk.
+ // For simplicity in streaming, we can emit a separate content chunk for grounding.
+
+ // However, we are inside a loop over candidates.
+ // Let's modify the template to include this in the content delta if possible.
+ // Or just append to the first text part found?
+ // Or emit a new delta?
+
+ // Safest: Emit a dedicated text delta for the grounding info.
+ groundingTemplate := template // clone
+ groundingTemplate, _ = sjson.Set(groundingTemplate, "choices.0.delta.content", groundingText.String())
+ groundingTemplate, _ = sjson.Set(groundingTemplate, "choices.0.delta.role", "assistant")
+ responseStrings = append(responseStrings, groundingTemplate)
+ }
+ }
+
if partsResult.IsArray() {
- partResults := partsResult.Array()
- for i := 0; i < len(partResults); i++ {
- partResult := partResults[i]
+ partsResults := partsResult.Array()
+ for i := 0; i < len(partsResults); i++ {
+ partResult := partsResults[i]
partTextResult := partResult.Get("text")
functionCallResult := partResult.Get("functionCall")
+ executableCodeResult := partResult.Get("executableCode")
+ codeExecutionResult := partResult.Get("codeExecutionResult")
inlineDataResult := partResult.Get("inlineData")
if !inlineDataResult.Exists() {
inlineDataResult = partResult.Get("inline_data")
}
- thoughtSignatureResult := partResult.Get("thoughtSignature")
- if !thoughtSignatureResult.Exists() {
- thoughtSignatureResult = partResult.Get("thought_signature")
- }
-
- hasThoughtSignature := thoughtSignatureResult.Exists() && thoughtSignatureResult.String() != ""
- hasContentPayload := partTextResult.Exists() || functionCallResult.Exists() || inlineDataResult.Exists()
-
- // Skip pure thoughtSignature parts but keep any actual payload in the same part.
- if hasThoughtSignature && !hasContentPayload {
- continue
- }
if partTextResult.Exists() {
text := partTextResult.String()
- // Handle text content, distinguishing between regular content and reasoning/thoughts.
+ // Handle text content, distinguishing between regular content and reasoning.
if partResult.Get("thought").Bool() {
template, _ = sjson.Set(template, "choices.0.delta.reasoning_content", text)
} else {
@@ -171,7 +195,7 @@ func ConvertGeminiResponseToOpenAI(_ context.Context, _ string, originalRequestR
}
template, _ = sjson.Set(template, "choices.0.delta.role", "assistant")
} else if functionCallResult.Exists() {
- // Handle function call content.
+ // Append function call content to the tool_calls array.
hasFunctionCall = true
toolCallsResult := gjson.Get(template, "choices.0.delta.tool_calls")
@@ -185,176 +209,51 @@ func ConvertGeminiResponseToOpenAI(_ context.Context, _ string, originalRequestR
template, _ = sjson.SetRaw(template, "choices.0.delta.tool_calls", `[]`)
}
- functionCallTemplate := `{"id": "","index": 0,"type": "function","function": {"name": "","arguments": ""}}`
+ functionCallItemTemplate := `{"id": "","index": 0,"type": "function","function": {"name": "","arguments": ""}}`
fcName := functionCallResult.Get("name").String()
- functionCallTemplate, _ = sjson.Set(functionCallTemplate, "id", fmt.Sprintf("%s-%d-%d", fcName, time.Now().UnixNano(), atomic.AddUint64(&functionCallIDCounter, 1)))
- functionCallTemplate, _ = sjson.Set(functionCallTemplate, "index", functionCallIndex)
- functionCallTemplate, _ = sjson.Set(functionCallTemplate, "function.name", fcName)
+ functionCallItemTemplate, _ = sjson.Set(functionCallItemTemplate, "id", fmt.Sprintf("%s-%d-%d", fcName, time.Now().UnixNano(), atomic.AddUint64(&functionCallIDCounter, 1)))
+ functionCallItemTemplate, _ = sjson.Set(functionCallItemTemplate, "index", functionCallIndex)
+ functionCallItemTemplate, _ = sjson.Set(functionCallItemTemplate, "function.name", fcName)
if fcArgsResult := functionCallResult.Get("args"); fcArgsResult.Exists() {
- functionCallTemplate, _ = sjson.Set(functionCallTemplate, "function.arguments", fcArgsResult.Raw)
- }
- template, _ = sjson.Set(template, "choices.0.delta.role", "assistant")
- template, _ = sjson.SetRaw(template, "choices.0.delta.tool_calls.-1", functionCallTemplate)
- } else if inlineDataResult.Exists() {
- data := inlineDataResult.Get("data").String()
- if data == "" {
- continue
- }
- mimeType := inlineDataResult.Get("mimeType").String()
- if mimeType == "" {
- mimeType = inlineDataResult.Get("mime_type").String()
- }
- if mimeType == "" {
- mimeType = "image/png"
- }
- imageURL := fmt.Sprintf("data:%s;base64,%s", mimeType, data)
- imagesResult := gjson.Get(template, "choices.0.delta.images")
- if !imagesResult.Exists() || !imagesResult.IsArray() {
- template, _ = sjson.SetRaw(template, "choices.0.delta.images", `[]`)
+ functionCallItemTemplate, _ = sjson.Set(functionCallItemTemplate, "function.arguments", fcArgsResult.Raw)
}
- imageIndex := len(gjson.Get(template, "choices.0.delta.images").Array())
- imagePayload := `{"type":"image_url","image_url":{"url":""}}`
- imagePayload, _ = sjson.Set(imagePayload, "index", imageIndex)
- imagePayload, _ = sjson.Set(imagePayload, "image_url.url", imageURL)
template, _ = sjson.Set(template, "choices.0.delta.role", "assistant")
- template, _ = sjson.SetRaw(template, "choices.0.delta.images.-1", imagePayload)
- }
- }
- }
-
- if hasFunctionCall {
- template, _ = sjson.Set(template, "choices.0.finish_reason", "tool_calls")
- template, _ = sjson.Set(template, "choices.0.native_finish_reason", "tool_calls")
- }
-
- responseStrings = append(responseStrings, template)
- return true // continue loop
- })
- } else {
- // If there are no candidates (e.g., a pure usageMetadata chunk), return the usage chunk if present.
- if gjson.GetBytes(rawJSON, "usageMetadata").Exists() && len(responseStrings) == 0 {
- responseStrings = append(responseStrings, baseTemplate)
- }
- }
-
- return responseStrings
-}
-
-// ConvertGeminiResponseToOpenAINonStream converts a non-streaming Gemini response to a non-streaming OpenAI response.
-// This function processes the complete Gemini response and transforms it into a single OpenAI-compatible
-// JSON response. It handles message content, tool calls, reasoning content, and usage metadata, combining all
-// the information into a single response that matches the OpenAI API format.
-//
-// Parameters:
-// - ctx: The context for the request, used for cancellation and timeout handling
-// - modelName: The name of the model being used for the response (unused in current implementation)
-// - rawJSON: The raw JSON response from the Gemini API
-// - param: A pointer to a parameter object for the conversion (unused in current implementation)
-//
-// Returns:
-// - string: An OpenAI-compatible JSON response containing all message content and metadata
-func ConvertGeminiResponseToOpenAINonStream(_ context.Context, _ string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, _ *any) string {
- var unixTimestamp int64
- // Initialize template with an empty choices array to support multiple candidates.
- template := `{"id":"","object":"chat.completion","created":123456,"model":"model","choices":[]}`
-
- if modelVersionResult := gjson.GetBytes(rawJSON, "modelVersion"); modelVersionResult.Exists() {
- template, _ = sjson.Set(template, "model", modelVersionResult.String())
- }
+ template, _ = sjson.SetRaw(template, "choices.0.delta.tool_calls.-1", functionCallItemTemplate)
+ } else if executableCodeResult.Exists() {
+ // Handle Gemini Code Execution (executableCode)
+ hasFunctionCall = true
+ toolCallsResult := gjson.Get(template, "choices.0.delta.tool_calls")
- if createTimeResult := gjson.GetBytes(rawJSON, "createTime"); createTimeResult.Exists() {
- t, err := time.Parse(time.RFC3339Nano, createTimeResult.String())
- if err == nil {
- unixTimestamp = t.Unix()
- }
- template, _ = sjson.Set(template, "created", unixTimestamp)
- } else {
- template, _ = sjson.Set(template, "created", unixTimestamp)
- }
+ // Retrieve the function index for this specific candidate.
+ functionCallIndex := p.FunctionIndex[candidateIndex]
+ p.FunctionIndex[candidateIndex]++
- if responseIDResult := gjson.GetBytes(rawJSON, "responseId"); responseIDResult.Exists() {
- template, _ = sjson.Set(template, "id", responseIDResult.String())
- }
+ if toolCallsResult.Exists() && toolCallsResult.IsArray() {
+ functionCallIndex = len(toolCallsResult.Array())
+ } else {
+ template, _ = sjson.SetRaw(template, "choices.0.delta.tool_calls", `[]`)
+ }
- if usageResult := gjson.GetBytes(rawJSON, "usageMetadata"); usageResult.Exists() {
- if candidatesTokenCountResult := usageResult.Get("candidatesTokenCount"); candidatesTokenCountResult.Exists() {
- template, _ = sjson.Set(template, "usage.completion_tokens", candidatesTokenCountResult.Int())
- }
- if totalTokenCountResult := usageResult.Get("totalTokenCount"); totalTokenCountResult.Exists() {
- template, _ = sjson.Set(template, "usage.total_tokens", totalTokenCountResult.Int())
- }
- promptTokenCount := usageResult.Get("promptTokenCount").Int()
- thoughtsTokenCount := usageResult.Get("thoughtsTokenCount").Int()
- cachedTokenCount := usageResult.Get("cachedContentTokenCount").Int()
- template, _ = sjson.Set(template, "usage.prompt_tokens", promptTokenCount+thoughtsTokenCount)
- if thoughtsTokenCount > 0 {
- template, _ = sjson.Set(template, "usage.completion_tokens_details.reasoning_tokens", thoughtsTokenCount)
- }
- // Include cached token count if present (indicates prompt caching is working)
- if cachedTokenCount > 0 {
- var err error
- template, err = sjson.Set(template, "usage.prompt_tokens_details.cached_tokens", cachedTokenCount)
- if err != nil {
- log.Warnf("gemini openai response: failed to set cached_tokens in non-streaming: %v", err)
- }
- }
- }
+ code := executableCodeResult.Get("code").String()
+ codeArgs := fmt.Sprintf(`{"code": %q}`, code)
- // Process the main content part of the response for all candidates.
- candidates := gjson.GetBytes(rawJSON, "candidates")
- if candidates.IsArray() {
- candidates.ForEach(func(_, candidate gjson.Result) bool {
- // Construct a single Choice object.
- choiceTemplate := `{"index":0,"message":{"role":"assistant","content":null,"reasoning_content":null,"tool_calls":null},"finish_reason":null,"native_finish_reason":null}`
+ functionCallItemTemplate := `{"id": "","index": 0,"type": "function","function": {"name": "python","arguments": ""}}`
+ functionCallItemTemplate, _ = sjson.Set(functionCallItemTemplate, "id", fmt.Sprintf("call_code_%d_%d", time.Now().UnixNano(), atomic.AddUint64(&functionCallIDCounter, 1)))
+ functionCallItemTemplate, _ = sjson.Set(functionCallItemTemplate, "index", functionCallIndex)
+ functionCallItemTemplate, _ = sjson.SetRaw(functionCallItemTemplate, "function.arguments", codeArgs)
- // Set the index for this choice.
- choiceTemplate, _ = sjson.Set(choiceTemplate, "index", candidate.Get("index").Int())
+ template, _ = sjson.Set(template, "choices.0.delta.role", "assistant")
+ template, _ = sjson.SetRaw(template, "choices.0.delta.tool_calls.-1", functionCallItemTemplate)
- // Set finish reason.
- if finishReasonResult := candidate.Get("finishReason"); finishReasonResult.Exists() {
- choiceTemplate, _ = sjson.Set(choiceTemplate, "finish_reason", strings.ToLower(finishReasonResult.String()))
- choiceTemplate, _ = sjson.Set(choiceTemplate, "native_finish_reason", strings.ToLower(finishReasonResult.String()))
- }
+ } else if codeExecutionResult.Exists() {
+ // Handle Gemini Code Execution Result
+ output := codeExecutionResult.Get("output").String()
+ outcome := codeExecutionResult.Get("outcome").String()
+ displayText := fmt.Sprintf("\n\n> **Code Execution (%s):**\n```\n%s\n```\n", outcome, output)
- partsResult := candidate.Get("content.parts")
- hasFunctionCall := false
- if partsResult.IsArray() {
- partsResults := partsResult.Array()
- for i := 0; i < len(partsResults); i++ {
- partResult := partsResults[i]
- partTextResult := partResult.Get("text")
- functionCallResult := partResult.Get("functionCall")
- inlineDataResult := partResult.Get("inlineData")
- if !inlineDataResult.Exists() {
- inlineDataResult = partResult.Get("inline_data")
- }
+ template, _ = sjson.Set(template, "choices.0.delta.content", displayText)
+ template, _ = sjson.Set(template, "choices.0.delta.role", "assistant")
- if partTextResult.Exists() {
- // Append text content, distinguishing between regular content and reasoning.
- if partResult.Get("thought").Bool() {
- oldVal := gjson.Get(choiceTemplate, "message.reasoning_content").String()
- choiceTemplate, _ = sjson.Set(choiceTemplate, "message.reasoning_content", oldVal+partTextResult.String())
- } else {
- oldVal := gjson.Get(choiceTemplate, "message.content").String()
- choiceTemplate, _ = sjson.Set(choiceTemplate, "message.content", oldVal+partTextResult.String())
- }
- choiceTemplate, _ = sjson.Set(choiceTemplate, "message.role", "assistant")
- } else if functionCallResult.Exists() {
- // Append function call content to the tool_calls array.
- hasFunctionCall = true
- toolCallsResult := gjson.Get(choiceTemplate, "message.tool_calls")
- if !toolCallsResult.Exists() || !toolCallsResult.IsArray() {
- choiceTemplate, _ = sjson.SetRaw(choiceTemplate, "message.tool_calls", `[]`)
- }
- functionCallItemTemplate := `{"id": "","type": "function","function": {"name": "","arguments": ""}}`
- fcName := functionCallResult.Get("name").String()
- functionCallItemTemplate, _ = sjson.Set(functionCallItemTemplate, "id", fmt.Sprintf("%s-%d-%d", fcName, time.Now().UnixNano(), atomic.AddUint64(&functionCallIDCounter, 1)))
- functionCallItemTemplate, _ = sjson.Set(functionCallItemTemplate, "function.name", fcName)
- if fcArgsResult := functionCallResult.Get("args"); fcArgsResult.Exists() {
- functionCallItemTemplate, _ = sjson.Set(functionCallItemTemplate, "function.arguments", fcArgsResult.Raw)
- }
- choiceTemplate, _ = sjson.Set(choiceTemplate, "message.role", "assistant")
- choiceTemplate, _ = sjson.SetRaw(choiceTemplate, "message.tool_calls.-1", functionCallItemTemplate)
} else if inlineDataResult.Exists() {
data := inlineDataResult.Get("data").String()
if data != "" {
diff --git a/internal/translator/gemini/openai/responses/gemini_openai-responses_request.go b/internal/translator/gemini/openai/responses/gemini_openai-responses_request.go
index 5277b71b2ed436b8608ae4b98461abcc4a451ae6..4ae648d356c9227a7cd77d1c49f8021e38d9f701 100644
--- a/internal/translator/gemini/openai/responses/gemini_openai-responses_request.go
+++ b/internal/translator/gemini/openai/responses/gemini_openai-responses_request.go
@@ -319,6 +319,7 @@ func ConvertOpenAIResponsesRequestToGemini(modelName string, inputRawJSON []byte
// Convert tools to Gemini functionDeclarations format
if tools := root.Get("tools"); tools.Exists() && tools.IsArray() {
geminiTools := `[{"functionDeclarations":[]}]`
+ hasCodeExecution := false
tools.ForEach(func(_, tool gjson.Result) bool {
if tool.Get("type").String() == "function" {
@@ -350,16 +351,53 @@ func ConvertOpenAIResponsesRequestToGemini(modelName string, inputRawJSON []byte
}
geminiTools, _ = sjson.SetRaw(geminiTools, "0.functionDeclarations.-1", funcDecl)
+ } else if tool.Get("type").String() == "code_interpreter" {
+ hasCodeExecution = true
}
return true
})
- // Only add tools if there are function declarations
- if funcDecls := gjson.Get(geminiTools, "0.functionDeclarations"); funcDecls.Exists() && len(funcDecls.Array()) > 0 {
+ if hasCodeExecution {
+ geminiTools, _ = sjson.SetRaw(geminiTools, "0.codeExecution", `{}`)
+ }
+
+ // Only add tools if there are function declarations or code execution
+ if funcDecls := gjson.Get(geminiTools, "0.functionDeclarations"); (funcDecls.Exists() && len(funcDecls.Array()) > 0) || hasCodeExecution {
out, _ = sjson.SetRaw(out, "tools", geminiTools)
}
}
+ // Tool Choice (function calling config)
+ toolChoice := root.Get("tool_choice")
+ if toolChoice.Exists() {
+ mode := "AUTO" // Default
+ var allowedNames []string
+
+ if toolChoice.Type == gjson.String {
+ tcStr := toolChoice.String()
+ if tcStr == "none" {
+ mode = "NONE"
+ } else if tcStr == "auto" {
+ mode = "AUTO"
+ } else if tcStr == "required" {
+ mode = "ANY"
+ }
+ } else if toolChoice.IsObject() {
+ if toolChoice.Get("type").String() == "function" {
+ mode = "ANY"
+ name := toolChoice.Get("function.name").String()
+ if name != "" {
+ allowedNames = append(allowedNames, name)
+ }
+ }
+ }
+
+ out, _ = sjson.Set(out, "toolConfig.functionCallingConfig.mode", mode)
+ if len(allowedNames) > 0 {
+ out, _ = sjson.Set(out, "toolConfig.functionCallingConfig.allowedFunctionNames", allowedNames)
+ }
+ }
+
// Handle generation config from OpenAI format
if maxOutputTokens := root.Get("max_output_tokens"); maxOutputTokens.Exists() {
genConfig := `{"maxOutputTokens":0}`
@@ -404,11 +442,39 @@ func ConvertOpenAIResponsesRequestToGemini(modelName string, inputRawJSON []byte
if effort != "" {
thinkingPath := "generationConfig.thinkingConfig"
if effort == "auto" {
- out, _ = sjson.Set(out, thinkingPath+".thinkingBudget", -1)
out, _ = sjson.Set(out, thinkingPath+".includeThoughts", true)
} else {
- out, _ = sjson.Set(out, thinkingPath+".thinkingLevel", effort)
- out, _ = sjson.Set(out, thinkingPath+".includeThoughts", effort != "none")
+ // Map levels to budget (approximations as Gemini uses raw token counts)
+ var budget int
+ switch effort {
+ case "low":
+ budget = 2048
+ case "medium":
+ budget = 8192
+ case "high":
+ budget = 32768
+ default:
+ budget = 0
+ }
+
+ if effort != "none" {
+ out, _ = sjson.Set(out, thinkingPath+".includeThoughts", true)
+ if budget > 0 {
+ out, _ = sjson.Set(out, thinkingPath+".thinkingBudgetTokenCount", budget)
+ }
+ } else {
+ out, _ = sjson.Set(out, thinkingPath+".includeThoughts", false)
+ }
+ }
+ }
+ } else {
+ // Pass through raw thinking_config if provided directly in the request (e.g. via extra_body)
+ if tc := root.Get("thinking_config"); tc.Exists() {
+ if include := tc.Get("include_thoughts"); include.Exists() {
+ out, _ = sjson.Set(out, "generationConfig.thinkingConfig.includeThoughts", include.Bool())
+ }
+ if budget := tc.Get("thinking_budget_token_count"); budget.Exists() {
+ out, _ = sjson.Set(out, "generationConfig.thinkingConfig.thinkingBudgetTokenCount", budget.Int())
}
}
}
diff --git a/internal/translator/gemini/openai/responses/gemini_openai-responses_response.go b/internal/translator/gemini/openai/responses/gemini_openai-responses_response.go
index 985897fab932d0354a136b440e7cb71900d79d76..f05cf6a08d0bdcf3b3fc358b3ffd912b2c63393a 100644
--- a/internal/translator/gemini/openai/responses/gemini_openai-responses_response.go
+++ b/internal/translator/gemini/openai/responses/gemini_openai-responses_response.go
@@ -40,6 +40,9 @@ type geminiToResponsesState struct {
FuncNames map[int]string
FuncCallIDs map[int]string
FuncDone map[int]bool
+
+ // code execution tracking
+ LastCodeCallID string
}
// responseIDCounter provides a process-wide unique counter for synthesized response identifiers.
@@ -301,8 +304,8 @@ func ConvertGeminiResponseToOpenAIResponses(_ context.Context, modelName string,
// Function call
if fc := part.Get("functionCall"); fc.Exists() {
- // Before emitting function-call outputs, finalize reasoning and the message (if open).
- // Responses streaming requires message done events before the next output_item.added.
+ // ... existing function call logic ...
+ // (No changes needed here, just context matching)
finalizeReasoning()
finalizeMessage()
name := fc.Get("name").String()
@@ -335,7 +338,6 @@ func ConvertGeminiResponseToOpenAIResponses(_ context.Context, modelName string,
out = append(out, emitEvent("response.output_item.added", item))
// Emit arguments delta (full args in one chunk).
- // When Gemini omits args, emit "{}" to keep Responses streaming event order consistent.
if argsJSON != "" {
ad := `{"type":"response.function_call_arguments.delta","sequence_number":0,"item_id":"","output_index":0,"delta":""}`
ad, _ = sjson.Set(ad, "sequence_number", nextSeq())
@@ -345,7 +347,6 @@ func ConvertGeminiResponseToOpenAIResponses(_ context.Context, modelName string,
out = append(out, emitEvent("response.function_call_arguments.delta", ad))
}
- // Gemini emits the full function call payload at once, so we can finalize it immediately.
if !st.FuncDone[idx] {
fcDone := `{"type":"response.function_call_arguments.done","sequence_number":0,"item_id":"","output_index":0,"arguments":""}`
fcDone, _ = sjson.Set(fcDone, "sequence_number", nextSeq())
@@ -369,6 +370,102 @@ func ConvertGeminiResponseToOpenAIResponses(_ context.Context, modelName string,
return true
}
+ // Code Execution (executableCode)
+ if exc := part.Get("executableCode"); exc.Exists() {
+ finalizeReasoning()
+ finalizeMessage()
+
+ code := exc.Get("code").String()
+ // Wrap code in arguments object
+ argsJSON := fmt.Sprintf(`{"code": %q}`, code)
+
+ idx := st.NextIndex
+ st.NextIndex++
+ callID := fmt.Sprintf("call_code_%d_%d", time.Now().UnixNano(), atomic.AddUint64(&funcCallIDCounter, 1))
+ st.LastCodeCallID = callID // Store for result mapping
+
+ // Emit function_call item (python)
+ item := `{"type":"response.output_item.added","sequence_number":0,"output_index":0,"item":{"id":"","type":"function_call","status":"completed","arguments":"","call_id":"","name":"python"}}`
+ item, _ = sjson.Set(item, "sequence_number", nextSeq())
+ item, _ = sjson.Set(item, "output_index", idx)
+ item, _ = sjson.Set(item, "item.id", fmt.Sprintf("fc_%s", callID))
+ item, _ = sjson.Set(item, "item.call_id", callID)
+ item, _ = sjson.Set(item, "item.arguments", argsJSON)
+ out = append(out, emitEvent("response.output_item.added", item))
+
+ // Emit done events immediately as Gemini sends full code block
+ // Note: skipping delta/arguments.done for simplicity as item is status=completed,
+ // but strict protocol might prefer them. Let's rely on item.done.
+
+ itemDone := `{"type":"response.output_item.done","sequence_number":0,"output_index":0,"item":{"id":"","type":"function_call","status":"completed","arguments":"","call_id":"","name":"python"}}`
+ itemDone, _ = sjson.Set(itemDone, "sequence_number", nextSeq())
+ itemDone, _ = sjson.Set(itemDone, "output_index", idx)
+ itemDone, _ = sjson.Set(itemDone, "item.id", fmt.Sprintf("fc_%s", callID))
+ itemDone, _ = sjson.Set(itemDone, "item.arguments", argsJSON)
+ itemDone, _ = sjson.Set(itemDone, "item.call_id", callID)
+ out = append(out, emitEvent("response.output_item.done", itemDone))
+
+ return true
+ }
+
+ // Code Execution Result (codeExecutionResult)
+ if res := part.Get("codeExecutionResult"); res.Exists() {
+ finalizeReasoning()
+ finalizeMessage()
+
+ output := res.Get("output").String()
+ // We map this to a function_call_output item
+ if st.LastCodeCallID != "" {
+ idx := st.NextIndex
+ st.NextIndex++
+
+ item := `{"type":"response.output_item.added","sequence_number":0,"output_index":0,"item":{"id":"","type":"function_call_output","output":"","call_id":""}}`
+ item, _ = sjson.Set(item, "sequence_number", nextSeq())
+ item, _ = sjson.Set(item, "output_index", idx)
+ item, _ = sjson.Set(item, "item.id", fmt.Sprintf("out_%s", st.LastCodeCallID))
+ item, _ = sjson.Set(item, "item.call_id", st.LastCodeCallID)
+ item, _ = sjson.Set(item, "item.output", output)
+ out = append(out, emitEvent("response.output_item.added", item))
+
+ itemDone := `{"type":"response.output_item.done","sequence_number":0,"output_index":0,"item":{"id":"","type":"function_call_output","output":"","call_id":""}}`
+ itemDone, _ = sjson.Set(itemDone, "sequence_number", nextSeq())
+ itemDone, _ = sjson.Set(itemDone, "output_index", idx)
+ itemDone, _ = sjson.Set(itemDone, "item.id", fmt.Sprintf("out_%s", st.LastCodeCallID))
+ itemDone, _ = sjson.Set(itemDone, "item.call_id", st.LastCodeCallID)
+ itemDone, _ = sjson.Set(itemDone, "item.output", output)
+ out = append(out, emitEvent("response.output_item.done", itemDone))
+
+ st.LastCodeCallID = "" // Consume ID
+ } else {
+ // Fallback: if we missed the call or it's out of sync, append to message text
+ // or ignore. Safest is to treat as message text to ensure visibility.
+ if !st.MsgOpened {
+ st.MsgOpened = true
+ st.MsgIndex = st.NextIndex
+ st.NextIndex++
+ st.CurrentMsgID = fmt.Sprintf("msg_%s_%d", st.ResponseID, st.MsgIndex)
+ item := `{"type":"response.output_item.added","sequence_number":0,"output_index":0,"item":{"id":"","type":"message","status":"in_progress","content":[],"role":"assistant"}}`
+ item, _ = sjson.Set(item, "sequence_number", nextSeq())
+ item, _ = sjson.Set(item, "output_index", st.MsgIndex)
+ item, _ = sjson.Set(item, "item.id", st.CurrentMsgID)
+ out = append(out, emitEvent("response.output_item.added", item))
+ }
+
+ displayText := fmt.Sprintf("\n> Code Execution Result:\n```\n%s\n```\n", output)
+ st.TextBuf.WriteString(displayText)
+ st.ItemTextBuf.WriteString(displayText)
+
+ msg := `{"type":"response.output_text.delta","sequence_number":0,"item_id":"","output_index":0,"content_index":0,"delta":""}`
+ msg, _ = sjson.Set(msg, "sequence_number", nextSeq())
+ msg, _ = sjson.Set(msg, "item_id", st.CurrentMsgID)
+ msg, _ = sjson.Set(msg, "output_index", st.MsgIndex)
+ msg, _ = sjson.Set(msg, "delta", displayText)
+ out = append(out, emitEvent("response.output_text.delta", msg))
+ }
+
+ return true
+ }
+
return true
})
}
@@ -662,6 +759,7 @@ func ConvertGeminiResponseToOpenAIResponsesNonStream(_ context.Context, _ string
var reasoningEncrypted string
var messageText strings.Builder
var haveMessage bool
+ var lastCodeCallID string
haveOutput := false
ensureOutput := func() {
@@ -708,6 +806,37 @@ func ConvertGeminiResponseToOpenAIResponsesNonStream(_ context.Context, _ string
appendOutput(itemJSON)
return true
}
+ if exc := p.Get("executableCode"); exc.Exists() {
+ code := exc.Get("code").String()
+ callID := fmt.Sprintf("call_code_%x_%d", time.Now().UnixNano(), atomic.AddUint64(&funcCallIDCounter, 1))
+ lastCodeCallID = callID // Track for result
+
+ itemJSON := `{"id":"","type":"function_call","status":"completed","arguments":"","call_id":"","name":"python"}`
+ itemJSON, _ = sjson.Set(itemJSON, "id", fmt.Sprintf("fc_%s", callID))
+ itemJSON, _ = sjson.Set(itemJSON, "call_id", callID)
+ argsStr := fmt.Sprintf(`{"code": %q}`, code)
+ itemJSON, _ = sjson.Set(itemJSON, "arguments", argsStr)
+ appendOutput(itemJSON)
+ return true
+ }
+ if res := p.Get("codeExecutionResult"); res.Exists() {
+ output := res.Get("output").String()
+
+ if lastCodeCallID != "" {
+ itemJSON := `{"id":"","type":"function_call_output","output":"","call_id":""}`
+ itemJSON, _ = sjson.Set(itemJSON, "id", fmt.Sprintf("out_%s", lastCodeCallID))
+ itemJSON, _ = sjson.Set(itemJSON, "call_id", lastCodeCallID)
+ itemJSON, _ = sjson.Set(itemJSON, "output", output)
+ appendOutput(itemJSON)
+ lastCodeCallID = ""
+ } else {
+ // Fallback: append to message text
+ displayText := fmt.Sprintf("\n> Code Execution Result:\n```\n%s\n```\n", output)
+ messageText.WriteString(displayText)
+ haveMessage = true
+ }
+ return true
+ }
return true
})
}
diff --git a/internal/translator/openai/claude/openai_claude_request.go b/internal/translator/openai/claude/openai_claude_request.go
index dc832e9ceeb71608f80e939937f29f257425c166..8f9e6d1ad1ed7706d5173b0616e768ae7f398be1 100644
--- a/internal/translator/openai/claude/openai_claude_request.go
+++ b/internal/translator/openai/claude/openai_claude_request.go
@@ -244,7 +244,6 @@ func ConvertClaudeRequestToOpenAI(modelName string, inputRawJSON []byte, stream
// tool_results already emitted above, no additional user message needed
}
}
-
} else if contentResult.Exists() && contentResult.Type == gjson.String {
// Simple string content
msgJSON := `{"role":"","content":""}`
diff --git a/internal/translator/openai/openai/chat-completions/openai_openai_request.go b/internal/translator/openai/openai/chat-completions/openai_openai_request.go
index 211c0eb4a41ee96f23e975265098ca55a22d2bc9..833de4991cfb62f0436d7d62c9a78b048b08f744 100644
--- a/internal/translator/openai/openai/chat-completions/openai_openai_request.go
+++ b/internal/translator/openai/openai/chat-completions/openai_openai_request.go
@@ -4,6 +4,7 @@ package chat_completions
import (
"bytes"
+
"github.com/tidwall/sjson"
)
diff --git a/internal/util/ssh_helper.go b/internal/util/ssh_helper.go
index 2f81fcb365fad1645305b04d302b6f27c5ad9c37..8d88ac00033533407e1624eef0e884d61e2fc719 100644
--- a/internal/util/ssh_helper.go
+++ b/internal/util/ssh_helper.go
@@ -32,7 +32,7 @@ func getPublicIP() (string, error) {
for _, service := range ipServices {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
- req, err := http.NewRequestWithContext(ctx, "GET", service, nil)
+ req, err := http.NewRequestWithContext(ctx, "GET", service, http.NoBody)
if err != nil {
log.Debugf("Failed to create request to %s: %v", service, err)
continue
diff --git a/kiro-gateway/pyproject.toml b/kiro-gateway/pyproject.toml
new file mode 100644
index 0000000000000000000000000000000000000000..ba096ff5c7cc13248100ab9f3852c06c1c50a66d
--- /dev/null
+++ b/kiro-gateway/pyproject.toml
@@ -0,0 +1,27 @@
+[tool.ruff]
+target-version = "py311"
+line-length = 100
+
+[tool.ruff.lint]
+select = [
+ "E", # pycodestyle errors
+ "F", # Pyflakes
+ "I", # isort
+ "N", # pep8-naming
+ "W", # pycodestyle warnings
+ "UP", # pyupgrade
+ "B", # flake8-bugbear
+ "C4", # flake8-comprehensions
+ "SIM", # flake8-simplify
+]
+ignore = ["E501"] # Line too long (handled by formatter)
+
+[tool.ruff.lint.pydocstyle]
+convention = "google"
+
+[tool.mypy]
+python_version = "3.11"
+strict = true
+warn_return_any = true
+warn_unused_configs = true
+disallow_untyped_defs = true
diff --git a/plans/data-pipeline-roadmap.md b/old/data-pipeline-roadmap.md
similarity index 100%
rename from plans/data-pipeline-roadmap.md
rename to old/data-pipeline-roadmap.md
diff --git a/plans/phase2-prd-mcp.md b/old/phase2-prd-mcp.md
similarity index 100%
rename from plans/phase2-prd-mcp.md
rename to old/phase2-prd-mcp.md
diff --git a/plans/refactoring-architecture.md b/old/refactoring-architecture.md
similarity index 100%
rename from plans/refactoring-architecture.md
rename to old/refactoring-architecture.md
diff --git a/plans/README.md b/plans/README.md
new file mode 100644
index 0000000000000000000000000000000000000000..f3882649330015ae65810d7dedb83fd1aaf90af0
--- /dev/null
+++ b/plans/README.md
@@ -0,0 +1,214 @@
+# CLIProxyAPI Refactoring & Enhancement Plans
+
+This directory contains comprehensive implementation plans for upgrading and refactoring the CLIProxyAPI codebase.
+
+## Plans Overview
+
+### 1. [Code Quality & Linting](code-quality-linting.md)
+**Focus**: Establish comprehensive linting and code quality standards
+- golangci-lint configuration with 20+ linters
+- Python (Ruff) and TypeScript (ESLint) linting setup
+- Pre-commit hooks
+- CI/CD integration
+
+**Timeline**: Week 1
+**Effort**: 16 hours
+
+---
+
+### 2. [Error Handling Standardization](error-handling-refactor.md)
+**Focus**: Standardize error handling across the application
+- Domain-specific error types
+- Centralized error middleware
+- Structured error responses
+- Request ID tracking
+
+**Timeline**: Week 1-2
+**Effort**: 24 hours
+
+---
+
+### 3. [Configuration Management Refactoring](config-refactoring.md)
+**Focus**: Split monolithic config into Clean Architecture layers
+- Domain models extraction
+- Provider pattern for storage backends
+- Migration framework
+- Hot reload support
+
+**Timeline**: Week 1-3
+**Effort**: 40 hours
+
+---
+
+### 4. [Testing Infrastructure](testing-infrastructure.md)
+**Focus**: Improve test coverage from 13% to 70%+
+- Table-driven tests
+- Mock generation with mockery
+- Test containers for integration tests
+- Contract tests for translators
+
+**Timeline**: Week 2-4
+**Effort**: 48 hours
+
+---
+
+### 5. [Clean Architecture Migration](clean-architecture-migration.md)
+**Focus**: Refactor to Clean Architecture (Onion/Hexagonal)
+- Domain layer with entities and services
+- Application layer with use cases
+- Infrastructure layer with repositories
+- Thin HTTP handlers
+
+**Timeline**: Week 1-7
+**Effort**: 80 hours
+
+---
+
+### 6. [Performance & Observability](performance-observability.md)
+**Focus**: Add caching, tracing, metrics
+- Structured logging with slog
+- OpenTelemetry/Jaeger tracing
+- Prometheus metrics
+- Redis caching layer
+- Circuit breakers
+
+**Timeline**: Week 3-4
+**Effort**: 40 hours
+
+---
+
+### 7. [Security Hardening](security-hardening.md)
+**Focus**: Security improvements and hardening
+- Input validation middleware
+- Rate limiting (token bucket)
+- Secrets management (Vault integration)
+- Security headers (CSP, HSTS)
+- Audit logging
+- SAST/DAST in CI
+
+**Timeline**: Week 1-3
+**Effort**: 48 hours
+
+---
+
+### 8. [CI/CD & DevOps](ci-cd-devops.md)
+**Focus**: Automated deployment and infrastructure
+- GitHub Actions workflows
+- Multi-arch Docker builds
+- GoReleaser configuration
+- Terraform for AWS
+- Kubernetes manifests
+- Monitoring stack
+
+**Timeline**: Week 1-4
+**Effort**: 48 hours
+
+---
+
+## Implementation Roadmap
+
+### Phase 1: Foundation (Weeks 1-2)
+| Task | Plan | Priority |
+|------|------|----------|
+| Setup golangci-lint | Code Quality | High |
+| Error handling standardization | Error Handling | High |
+| Input validation middleware | Security | High |
+| Structured logging | Performance | Medium |
+
+### Phase 2: Core Improvements (Weeks 3-4)
+| Task | Plan | Priority |
+|------|------|----------|
+| Config refactoring start | Config | High |
+| Testing infrastructure | Testing | High |
+| Rate limiting | Security | Medium |
+| Metrics collection | Performance | Medium |
+
+### Phase 3: Architecture (Weeks 5-7)
+| Task | Plan | Priority |
+|------|------|----------|
+| Domain layer extraction | Clean Arch | High |
+| Use case implementation | Clean Arch | High |
+| Repository pattern | Clean Arch | High |
+| Handler refactoring | Clean Arch | Medium |
+
+### Phase 4: Scale (Weeks 8-10)
+| Task | Plan | Priority |
+|------|------|----------|
+| Caching layer | Performance | Medium |
+| Circuit breakers | Performance | Medium |
+| Tracing | Performance | Low |
+| CI/CD pipelines | CI/CD | High |
+
+### Phase 5: Production (Weeks 11-12)
+| Task | Plan | Priority |
+|------|------|----------|
+| Terraform infrastructure | CI/CD | Medium |
+| Kubernetes deployment | CI/CD | Medium |
+| Monitoring setup | CI/CD | Medium |
+| Security scanning | Security | High |
+
+## Effort Summary
+
+| Category | Hours | Percentage |
+|----------|-------|------------|
+| Code Quality | 16 | 6% |
+| Error Handling | 24 | 9% |
+| Configuration | 40 | 15% |
+| Testing | 48 | 18% |
+| Clean Architecture | 80 | 30% |
+| Performance | 40 | 15% |
+| Security | 48 | 18% |
+| CI/CD | 48 | 18% |
+| **Total** | **344** | **100%** |
+
+*Note: Hours are estimates and may overlap in some areas*
+
+## Quick Wins (Week 1)
+
+1. **Enable golangci-lint** - Immediate code quality improvement
+2. **Add security headers** - One middleware addition
+3. **Structured logging** - Replace logrus with slog
+4. **Request ID middleware** - Track requests end-to-end
+5. **Rate limiting** - Protect against abuse
+
+## Key Decisions Needed
+
+1. **Storage Backend**: Continue with PostgreSQL or add Redis?
+2. **Secrets Management**: HashiCorp Vault, cloud provider, or env vars?
+3. **Tracing**: Jaeger, Zipkin, or cloud provider (AWS X-Ray)?
+4. **Deployment**: Kubernetes, ECS, or stay with Docker Compose?
+5. **Caching**: Redis or in-memory only?
+
+## Risk Assessment
+
+| Risk | Impact | Mitigation |
+|------|--------|------------|
+| Breaking changes during refactor | High | Feature flags, gradual rollout |
+| Test coverage gaps | Medium | Focus on critical paths first |
+| Performance regression | Medium | Benchmark before/after |
+| Deployment complexity | Medium | Staging environment testing |
+| Team learning curve | Low | Code reviews, documentation |
+
+## Success Criteria
+
+- [ ] 70%+ test coverage
+- [ ] Zero lint errors
+- [ ] <100ms p99 latency
+- [ ] 99.9% uptime
+- [ ] Automated deployments
+- [ ] Full observability
+- [ ] Zero security vulnerabilities (high/critical)
+
+## Contributing to Plans
+
+When implementing these plans:
+
+1. Create feature branches from `develop`
+2. Reference plan file in commit messages
+3. Update plan status as you progress
+4. Add lessons learned to plan files
+5. Mark completed items with dates
+
+## Contact
+
+For questions about these plans, refer to the main project documentation or create an issue.
diff --git a/plans/ci-cd-devops.md b/plans/ci-cd-devops.md
new file mode 100644
index 0000000000000000000000000000000000000000..5235e2951859f18ee48364c49b4d140330d8a566
--- /dev/null
+++ b/plans/ci-cd-devops.md
@@ -0,0 +1,1059 @@
+# CI/CD & DevOps Enhancement Plan
+
+## Current State
+
+**Existing**:
+- Basic Go build workflow
+- Dockerfile for containerization
+- Docker Compose setup
+- GoReleaser configuration
+
+**Missing**:
+- Comprehensive testing in CI
+- Security scanning
+- Multi-environment deployment
+- Infrastructure as Code
+- Automated rollback
+- Release automation
+
+## Phase 1: Enhanced CI Pipeline (Week 1)
+
+### 1.1 Main CI Workflow
+**File**: `.github/workflows/ci.yml`
+
+```yaml
+name: CI
+
+on:
+ push:
+ branches: [main, develop]
+ tags: ['v*']
+ pull_request:
+ branches: [main, develop]
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ lint:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: actions/setup-go@v5
+ with:
+ go-version: '1.24'
+ cache: true
+
+ - name: golangci-lint
+ uses: golangci/golangci-lint-action@v6
+ with:
+ version: latest
+ args: --timeout=5m --out-format=colored-line-number
+
+ - name: Check formatting
+ run: |
+ fmt_files=$(gofmt -l .)
+ if [ -n "$fmt_files" ]; then
+ echo "The following files need formatting:"
+ echo "$fmt_files"
+ exit 1
+ fi
+
+ test:
+ runs-on: ubuntu-latest
+ services:
+ postgres:
+ image: postgres:16-alpine
+ env:
+ POSTGRES_PASSWORD: postgres
+ POSTGRES_DB: test
+ options: >-
+ --health-cmd pg_isready
+ --health-interval 10s
+ --health-timeout 5s
+ --health-retries 5
+ ports:
+ - 5432:5432
+
+ redis:
+ image: redis:7-alpine
+ options: >-
+ --health-cmd "redis-cli ping"
+ --health-interval 10s
+ --health-timeout 5s
+ --health-retries 5
+ ports:
+ - 6379:6379
+
+ minio:
+ image: minio/minio:latest
+ env:
+ MINIO_ROOT_USER: minioadmin
+ MINIO_ROOT_PASSWORD: minioadmin
+ ports:
+ - 9000:9000
+ options: >-
+ server /data
+
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: actions/setup-go@v5
+ with:
+ go-version: '1.24'
+ cache: true
+
+ - name: Download dependencies
+ run: go mod download
+
+ - name: Run unit tests
+ run: go test -race -coverprofile=coverage.out ./internal/... ./sdk/...
+ env:
+ CGO_ENABLED: 1
+
+ - name: Run integration tests
+ run: go test -v -tags=integration ./test/integration/...
+ env:
+ TEST_POSTGRES_URL: postgres://postgres:postgres@localhost:5432/test?sslmode=disable
+ TEST_REDIS_URL: redis://localhost:6379
+ TEST_MINIO_ENDPOINT: localhost:9000
+ TEST_MINIO_ACCESS_KEY: minioadmin
+ TEST_MINIO_SECRET_KEY: minioadmin
+
+ - name: Generate coverage report
+ run: |
+ go tool cover -html=coverage.out -o coverage.html
+ go tool cover -func=coverage.out
+
+ - name: Upload coverage to Codecov
+ uses: codecov/codecov-action@v3
+ with:
+ files: ./coverage.out
+ fail_ci_if_error: true
+ verbose: true
+
+ build:
+ runs-on: ubuntu-latest
+ needs: [lint, test]
+ strategy:
+ matrix:
+ include:
+ - platform: linux/amd64
+ goos: linux
+ goarch: amd64
+ - platform: linux/arm64
+ goos: linux
+ goarch: arm64
+ - platform: darwin/amd64
+ goos: darwin
+ goarch: amd64
+ - platform: darwin/arm64
+ goos: darwin
+ goarch: arm64
+ - platform: windows/amd64
+ goos: windows
+ goarch: amd64
+ extension: .exe
+
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: actions/setup-go@v5
+ with:
+ go-version: '1.24'
+ cache: true
+
+ - name: Build binary
+ env:
+ GOOS: ${{ matrix.goos }}
+ GOARCH: ${{ matrix.goarch }}
+ CGO_ENABLED: 0
+ run: |
+ go build -ldflags "-s -w -X main.version=${{ github.ref_name }} -X main.commit=${{ github.sha }} -X main.date=$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
+ -o dist/cliproxy-${{ matrix.goos }}-${{ matrix.goarch }}${{ matrix.extension }} \
+ ./cmd/server
+
+ - name: Upload artifact
+ uses: actions/upload-artifact@v4
+ with:
+ name: cliproxy-${{ matrix.goos }}-${{ matrix.goarch }}
+ path: dist/cliproxy-${{ matrix.goos }}-${{ matrix.goarch }}${{ matrix.extension }}
+ retention-days: 7
+
+ docker:
+ runs-on: ubuntu-latest
+ needs: [lint, test]
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@v3
+
+ - name: Login to Docker Hub
+ if: github.event_name != 'pull_request'
+ uses: docker/login-action@v3
+ with:
+ username: ${{ secrets.DOCKER_USERNAME }}
+ password: ${{ secrets.DOCKER_PASSWORD }}
+
+ - name: Login to GHCR
+ if: github.event_name != 'pull_request'
+ uses: docker/login-action@v3
+ with:
+ registry: ghcr.io
+ username: ${{ github.actor }}
+ password: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Docker meta
+ id: meta
+ uses: docker/metadata-action@v5
+ with:
+ images: |
+ ${{ secrets.DOCKER_USERNAME }}/cliproxy
+ ghcr.io/${{ github.repository }}
+ tags: |
+ type=ref,event=branch
+ type=ref,event=pr
+ type=semver,pattern={{version}}
+ type=semver,pattern={{major}}.{{minor}}
+ type=sha,prefix=,suffix=,format=short
+
+ - name: Build and push
+ uses: docker/build-push-action@v5
+ with:
+ context: .
+ platforms: linux/amd64,linux/arm64
+ push: ${{ github.event_name != 'pull_request' }}
+ tags: ${{ steps.meta.outputs.tags }}
+ labels: ${{ steps.meta.outputs.labels }}
+ cache-from: type=gha
+ cache-to: type=gha,mode=max
+ build-args: |
+ VERSION=${{ github.ref_name }}
+ COMMIT=${{ github.sha }}
+ DATE=${{ github.event.head_commit.timestamp }}
+
+ - name: Run Trivy vulnerability scanner
+ uses: aquasecurity/trivy-action@master
+ with:
+ image-ref: ghcr.io/${{ github.repository }}:${{ github.sha }}
+ format: 'sarif'
+ output: 'trivy-results.sarif'
+
+ - name: Upload Trivy results
+ uses: github/codeql-action/upload-sarif@v2
+ if: always()
+ with:
+ sarif_file: 'trivy-results.sarif'
+```
+
+### 1.2 PR Automation
+**File**: `.github/workflows/pr-automation.yml`
+
+```yaml
+name: PR Automation
+
+on:
+ pull_request:
+ types: [opened, synchronize, reopened, ready_for_review]
+
+jobs:
+ assign:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Auto-assign PR
+ uses: actions/github-script@v7
+ with:
+ script: |
+ const pr = context.payload.pull_request;
+ if (pr.user.type === 'User') {
+ github.rest.issues.addAssignees({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ issue_number: pr.number,
+ assignees: [pr.user.login]
+ });
+ }
+
+ size-label:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Label based on size
+ uses: codelytv/pr-size-labeler@v1
+ with:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ xs_label: 'size/xs'
+ xs_max_size: 50
+ s_label: 'size/s'
+ s_max_size: 200
+ m_label: 'size/m'
+ m_max_size: 500
+ l_label: 'size/l'
+ l_max_size: 1000
+ xl_label: 'size/xl'
+ fail_if_xl: false
+
+ dependency-review:
+ runs-on: ubuntu-latest
+ if: github.event_name == 'pull_request'
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/dependency-review-action@v3
+ with:
+ fail-on-severity: high
+```
+
+## Phase 2: Release Automation (Week 2)
+
+### 2.1 Release Workflow
+**File**: `.github/workflows/release.yml`
+
+```yaml
+name: Release
+
+on:
+ push:
+ tags:
+ - 'v*'
+
+permissions:
+ contents: write
+ packages: write
+
+jobs:
+ release:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+
+ - uses: actions/setup-go@v5
+ with:
+ go-version: '1.24'
+ cache: true
+
+ - name: Generate changelog
+ id: changelog
+ uses: mikepenz/release-changelog-builder-action@v4
+ with:
+ configuration: .github/changelog-config.json
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Run GoReleaser
+ uses: goreleaser/goreleaser-action@v5
+ with:
+ distribution: goreleaser
+ version: latest
+ args: release --clean
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
+ DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }}
+
+ deploy-hf:
+ runs-on: ubuntu-latest
+ needs: release
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Trigger Hugging Face Space update
+ run: |
+ curl -X POST \
+ -H "Authorization: Bearer ${{ secrets.HF_TOKEN }}" \
+ https://huggingface.co/api/spaces/${{ secrets.HF_SPACE }}/sync
+
+ - name: Wait for deployment
+ run: |
+ sleep 60
+ curl -s https://huggingface.co/api/spaces/${{ secrets.HF_SPACE }} | jq '.runtime.stage'
+```
+
+### 2.2 GoReleaser Config
+**File**: `.goreleaser.yml`
+
+```yaml
+version: 2
+
+before:
+ hooks:
+ - go mod tidy
+ - go generate ./...
+
+builds:
+ - id: cliproxy
+ main: ./cmd/server
+ binary: cliproxy
+ env:
+ - CGO_ENABLED=0
+ goos:
+ - linux
+ - darwin
+ - windows
+ goarch:
+ - amd64
+ - arm64
+ goarm:
+ - '7'
+ ldflags:
+ - -s -w
+ - -X main.version={{.Version}}
+ - -X main.commit={{.Commit}}
+ - -X main.date={{.Date}}
+
+archives:
+ - id: cliproxy
+ name_template: >-
+ {{ .ProjectName }}_
+ {{- title .Os }}_
+ {{- if eq .Arch "amd64" }}x86_64
+ {{- else if eq .Arch "386" }}i386
+ {{- else }}{{ .Arch }}{{ end }}
+ format_overrides:
+ - goos: windows
+ format: zip
+ files:
+ - README.md
+ - LICENSE
+ - config.example.yaml
+
+dockers:
+ - image_templates:
+ - "ghcr.io/echyai/cliproxy:{{ .Tag }}-amd64"
+ - "echyai/cliproxy:{{ .Tag }}-amd64"
+ dockerfile: Dockerfile
+ use: buildx
+ build_flag_templates:
+ - --platform=linux/amd64
+ - --label=org.opencontainers.image.title={{ .ProjectName }}
+ - --label=org.opencontainers.image.description={{ .ProjectName }}
+ - --label=org.opencontainers.image.url=https://github.com/echyai/cliproxy
+ - --label=org.opencontainers.image.source=https://github.com/echyai/cliproxy
+ - --label=org.opencontainers.image.version={{ .Version }}
+ - --label=org.opencontainers.image.created={{ .Date }}
+ - --label=org.opencontainers.image.revision={{ .FullCommit }}
+ - image_templates:
+ - "ghcr.io/echyai/cliproxy:{{ .Tag }}-arm64"
+ - "echyai/cliproxy:{{ .Tag }}-arm64"
+ dockerfile: Dockerfile
+ use: buildx
+ goarch: arm64
+ build_flag_templates:
+ - --platform=linux/arm64
+ - --label=org.opencontainers.image.title={{ .ProjectName }}
+ - --label=org.opencontainers.image.description={{ .ProjectName }}
+ - --label=org.opencontainers.image.url=https://github.com/echyai/cliproxy
+ - --label=org.opencontainers.image.source=https://github.com/echyai/cliproxy
+ - --label=org.opencontainers.image.version={{ .Version }}
+ - --label=org.opencontainers.image.created={{ .Date }}
+ - --label=org.opencontainers.image.revision={{ .FullCommit }}
+
+docker_manifests:
+ - name_template: "echyai/cliproxy:{{ .Tag }}"
+ image_templates:
+ - "echyai/cliproxy:{{ .Tag }}-amd64"
+ - "echyai/cliproxy:{{ .Tag }}-arm64"
+ - name_template: "echyai/cliproxy:latest"
+ image_templates:
+ - "echyai/cliproxy:{{ .Tag }}-amd64"
+ - "echyai/cliproxy:{{ .Tag }}-arm64"
+ - name_template: "ghcr.io/echyai/cliproxy:{{ .Tag }}"
+ image_templates:
+ - "ghcr.io/echyai/cliproxy:{{ .Tag }}-amd64"
+ - "ghcr.io/echyai/cliproxy:{{ .Tag }}-arm64"
+ - name_template: "ghcr.io/echyai/cliproxy:latest"
+ image_templates:
+ - "ghcr.io/echyai/cliproxy:{{ .Tag }}-amd64"
+ - "ghcr.io/echyai/cliproxy:{{ .Tag }}-arm64"
+
+changelog:
+ use: github
+ sort: asc
+ filters:
+ exclude:
+ - '^docs:'
+ - '^test:'
+ - '^chore:'
+ - '^ci:'
+ - Merge pull request
+ - Merge branch
+
+release:
+ github:
+ owner: echyai
+ name: cliproxy
+ draft: false
+ prerelease: auto
+ mode: replace
+ header: |
+ ## CLIProxyAPI {{ .Tag }}
+
+ ### Docker Images
+ ```bash
+ docker pull echyai/cliproxy:{{ .Tag }}
+ docker pull ghcr.io/echyai/cliproxy:{{ .Tag }}
+ ```
+
+ footer: |
+ ## Quick Start
+
+ ```bash
+ # Download and run
+ curl -L https://github.com/echyai/cliproxy/releases/download/{{ .Tag }}/cliproxy_Linux_x86_64.tar.gz | tar xz
+ ./cliproxy --config config.yaml
+ ```
+```
+
+## Phase 3: Infrastructure as Code (Week 3)
+
+### 3.1 Terraform for AWS
+**File**: `infrastructure/terraform/main.tf`
+
+```hcl
+terraform {
+ required_version = ">= 1.5"
+
+ required_providers {
+ aws = {
+ source = "hashicorp/aws"
+ version = "~> 5.0"
+ }
+ }
+
+ backend "s3" {
+ bucket = "cliproxy-terraform-state"
+ key = "infrastructure/terraform.tfstate"
+ region = "us-east-1"
+ encrypt = true
+ dynamodb_table = "cliproxy-terraform-locks"
+ }
+}
+
+provider "aws" {
+ region = var.aws_region
+
+ default_tags {
+ tags = {
+ Project = "cliproxy"
+ Environment = var.environment
+ ManagedBy = "terraform"
+ }
+ }
+}
+
+# VPC
+module "vpc" {
+ source = "terraform-aws-modules/vpc/aws"
+ version = "~> 5.0"
+
+ name = "${var.project_name}-${var.environment}"
+ cidr = var.vpc_cidr
+
+ azs = var.availability_zones
+ private_subnets = var.private_subnets
+ public_subnets = var.public_subnets
+
+ enable_nat_gateway = true
+ single_nat_gateway = var.environment != "production"
+ enable_dns_hostnames = true
+ enable_dns_support = true
+
+ public_subnet_tags = {
+ "kubernetes.io/role/elb" = "1"
+ }
+
+ private_subnet_tags = {
+ "kubernetes.io/role/internal-elb" = "1"
+ }
+}
+
+# EKS Cluster
+module "eks" {
+ source = "terraform-aws-modules/eks/aws"
+ version = "~> 19.0"
+
+ cluster_name = "${var.project_name}-${var.environment}"
+ cluster_version = "1.29"
+
+ vpc_id = module.vpc.vpc_id
+ subnet_ids = module.vpc.private_subnets
+ control_plane_subnet_ids = module.vpc.intra_subnets
+ cluster_endpoint_public_access = true
+
+ eks_managed_node_groups = {
+ general = {
+ desired_size = var.node_desired_size
+ min_size = var.node_min_size
+ max_size = var.node_max_size
+
+ instance_types = var.node_instance_types
+ capacity_type = var.environment == "production" ? "ON_DEMAND" : "SPOT"
+
+ labels = {
+ workload = "general"
+ }
+
+ update_config = {
+ max_unavailable_percentage = 25
+ }
+ }
+ }
+
+ # Fargate profiles for serverless workloads
+ fargate_profiles = {
+ default = {
+ name = "default"
+ selectors = [
+ { namespace = "kube-system" },
+ { namespace = "default" }
+ ]
+ }
+ }
+}
+
+# RDS PostgreSQL
+module "rds" {
+ source = "terraform-aws-modules/rds/aws"
+ version = "~> 6.0"
+
+ identifier = "${var.project_name}-${var.environment}"
+
+ engine = "postgres"
+ engine_version = "16"
+ family = "postgres16"
+ major_engine_version = "16"
+ instance_class = var.db_instance_class
+
+ allocated_storage = var.db_allocated_storage
+ max_allocated_storage = var.db_max_allocated_storage
+
+ db_name = var.db_name
+ username = var.db_username
+ port = 5432
+
+ multi_az = var.environment == "production"
+ db_subnet_group_name = module.vpc.database_subnet_group
+ vpc_security_group_ids = [aws_security_group.rds.id]
+
+ backup_retention_period = var.environment == "production" ? 7 : 1
+ backup_window = "03:00-04:00"
+ maintenance_window = "Mon:04:00-Mon:05:00"
+
+ deletion_protection = var.environment == "production"
+
+ enabled_cloudwatch_logs_exports = ["postgresql"]
+
+ performance_insights_enabled = var.environment == "production"
+}
+
+# ElastiCache Redis
+module "redis" {
+ source = "terraform-aws-modules/elasticache/aws"
+ version = "~> 1.0"
+
+ cluster_id = "${var.project_name}-${var.environment}"
+
+ engine = "redis"
+ engine_version = "7.1"
+ node_type = var.redis_node_type
+ num_cache_nodes = 1
+
+ subnet_group_name = aws_elasticache_subnet_group.redis.name
+ security_group_ids = [aws_security_group.redis.id]
+
+ at_rest_encryption_enabled = true
+ transit_encryption_enabled = true
+}
+
+# Application Load Balancer
+module "alb" {
+ source = "terraform-aws-modules/alb/aws"
+ version = "~> 9.0"
+
+ name = "${var.project_name}-${var.environment}"
+
+ load_balancer_type = "application"
+
+ vpc_id = module.vpc.vpc_id
+ subnets = module.vpc.public_subnets
+
+ security_groups = [aws_security_group.alb.id]
+
+ listeners = {
+ https = {
+ port = 443
+ protocol = "HTTPS"
+ certificate_arn = aws_acm_certificate.main.arn
+
+ fixed_response = {
+ content_type = "text/plain"
+ message_body = "OK"
+ status_code = "200"
+ }
+ }
+ }
+}
+
+# Route53
+resource "aws_route53_record" "main" {
+ zone_id = var.route53_zone_id
+ name = var.domain_name
+ type = "A"
+
+ alias {
+ name = module.alb.dns_name
+ zone_id = module.alb.zone_id
+ evaluate_target_health = true
+ }
+}
+```
+
+### 3.2 Kubernetes Manifests
+**File**: `infrastructure/k8s/base/deployment.yaml`
+
+```yaml
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: cliproxy
+ labels:
+ app: cliproxy
+spec:
+ replicas: 3
+ strategy:
+ type: RollingUpdate
+ rollingUpdate:
+ maxSurge: 25%
+ maxUnavailable: 10%
+ selector:
+ matchLabels:
+ app: cliproxy
+ template:
+ metadata:
+ labels:
+ app: cliproxy
+ annotations:
+ prometheus.io/scrape: "true"
+ prometheus.io/port: "8080"
+ prometheus.io/path: "/metrics"
+ spec:
+ serviceAccountName: cliproxy
+ securityContext:
+ runAsNonRoot: true
+ runAsUser: 1000
+ fsGroup: 1000
+ containers:
+ - name: cliproxy
+ image: echyai/cliproxy:latest
+ imagePullPolicy: Always
+ ports:
+ - name: http
+ containerPort: 8080
+ protocol: TCP
+ securityContext:
+ allowPrivilegeEscalation: false
+ readOnlyRootFilesystem: true
+ capabilities:
+ drop:
+ - ALL
+ resources:
+ requests:
+ memory: "256Mi"
+ cpu: "250m"
+ limits:
+ memory: "1Gi"
+ cpu: "1000m"
+ livenessProbe:
+ httpGet:
+ path: /health
+ port: http
+ initialDelaySeconds: 30
+ periodSeconds: 10
+ timeoutSeconds: 5
+ failureThreshold: 3
+ readinessProbe:
+ httpGet:
+ path: /ready
+ port: http
+ initialDelaySeconds: 5
+ periodSeconds: 5
+ timeoutSeconds: 3
+ failureThreshold: 3
+ env:
+ - name: CONFIG_TYPE
+ value: "postgres"
+ - name: POSTGRES_HOST
+ valueFrom:
+ secretKeyRef:
+ name: cliproxy-db
+ key: host
+ - name: POSTGRES_PORT
+ valueFrom:
+ secretKeyRef:
+ name: cliproxy-db
+ key: port
+ - name: POSTGRES_DATABASE
+ valueFrom:
+ secretKeyRef:
+ name: cliproxy-db
+ key: database
+ - name: POSTGRES_USERNAME
+ valueFrom:
+ secretKeyRef:
+ name: cliproxy-db
+ key: username
+ - name: POSTGRES_PASSWORD
+ valueFrom:
+ secretKeyRef:
+ name: cliproxy-db
+ key: password
+ volumeMounts:
+ - name: tmp
+ mountPath: /tmp
+ - name: cache
+ mountPath: /cache
+ volumes:
+ - name: tmp
+ emptyDir: {}
+ - name: cache
+ emptyDir:
+ sizeLimit: 500Mi
+---
+apiVersion: v1
+kind: Service
+metadata:
+ name: cliproxy
+ labels:
+ app: cliproxy
+spec:
+ type: ClusterIP
+ ports:
+ - port: 80
+ targetPort: http
+ protocol: TCP
+ name: http
+ selector:
+ app: cliproxy
+---
+apiVersion: networking.k8s.io/v1
+kind: Ingress
+metadata:
+ name: cliproxy
+ annotations:
+ kubernetes.io/ingress.class: alb
+ alb.ingress.kubernetes.io/scheme: internet-facing
+ alb.ingress.kubernetes.io/target-type: ip
+ alb.ingress.kubernetes.io/listen-ports: '[{"HTTPS":443}]'
+ alb.ingress.kubernetes.io/certificate-arn: arn:aws:acm:us-east-1:123456789012:certificate/...
+spec:
+ rules:
+ - host: api.cliproxy.example.com
+ http:
+ paths:
+ - path: /
+ pathType: Prefix
+ backend:
+ service:
+ name: cliproxy
+ port:
+ number: 80
+```
+
+### 3.3 Kustomize Overlays
+**File**: `infrastructure/k8s/overlays/production/kustomization.yaml`
+
+```yaml
+apiVersion: kustomize.config.k8s.io/v1beta1
+kind: Kustomization
+
+namespace: cliproxy-production
+
+resources:
+ - ../../base
+ - hpa.yaml
+ - pdb.yaml
+
+replicas:
+ - name: cliproxy
+ count: 5
+
+images:
+ - name: echyai/cliproxy
+ newTag: v1.2.3
+
+patchesStrategicMerge:
+ - deployment-patch.yaml
+
+configMapGenerator:
+ - name: cliproxy-config
+ literals:
+ - LOG_LEVEL=info
+ - METRICS_ENABLED=true
+
+secretGenerator:
+ - name: cliproxy-db
+ envs:
+ - .env.db
+
+commonLabels:
+ environment: production
+```
+
+## Phase 4: Monitoring Stack (Week 4)
+
+### 4.1 Prometheus Rules
+**File**: `infrastructure/monitoring/prometheus-rules.yaml`
+
+```yaml
+apiVersion: monitoring.coreos.com/v1
+kind: PrometheusRule
+metadata:
+ name: cliproxy-alerts
+spec:
+ groups:
+ - name: cliproxy
+ rules:
+ - alert: CLProxyHighErrorRate
+ expr: |
+ (
+ sum(rate(cliproxy_http_requests_total{status=~"5.."}[5m]))
+ /
+ sum(rate(cliproxy_http_requests_total[5m]))
+ ) > 0.05
+ for: 5m
+ labels:
+ severity: critical
+ annotations:
+ summary: "High error rate detected"
+ description: "Error rate is above 5% for 5 minutes"
+
+ - alert: CLProxyHighLatency
+ expr: |
+ histogram_quantile(0.99,
+ sum(rate(cliproxy_http_request_duration_seconds_bucket[5m])) by (le)
+ ) > 2
+ for: 5m
+ labels:
+ severity: warning
+ annotations:
+ summary: "High latency detected"
+ description: "P99 latency is above 2 seconds"
+
+ - alert: CLProxyProviderErrors
+ expr: |
+ sum(rate(cliproxy_provider_requests_total{status="error"}[5m])) by (provider) > 10
+ for: 5m
+ labels:
+ severity: warning
+ annotations:
+ summary: "Provider {{ $labels.provider }} has high error rate"
+
+ - alert: CLProxyCircuitBreakerOpen
+ expr: cliproxy_circuit_breaker_state == 2
+ for: 1m
+ labels:
+ severity: warning
+ annotations:
+ summary: "Circuit breaker is open"
+ description: "Circuit breaker {{ $labels.name }} has opened"
+
+ - alert: CLProxyRateLimitHits
+ expr: |
+ sum(rate(cliproxy_rate_limit_hits_total[5m])) > 100
+ for: 5m
+ labels:
+ severity: info
+ annotations:
+ summary: "High rate limit hits"
+```
+
+### 4.2 Grafana Dashboard
+**File**: `infrastructure/monitoring/dashboard.json` (simplified)
+
+```json
+{
+ "dashboard": {
+ "title": "CLIProxyAPI",
+ "tags": ["cliproxy", "api"],
+ "timezone": "UTC",
+ "panels": [
+ {
+ "title": "Request Rate",
+ "type": "graph",
+ "targets": [
+ {
+ "expr": "sum(rate(cliproxy_http_requests_total[5m])) by (status)"
+ }
+ ]
+ },
+ {
+ "title": "Latency",
+ "type": "graph",
+ "targets": [
+ {
+ "expr": "histogram_quantile(0.99, sum(rate(cliproxy_http_request_duration_seconds_bucket[5m])) by (le))"
+ }
+ ]
+ },
+ {
+ "title": "Provider Usage",
+ "type": "graph",
+ "targets": [
+ {
+ "expr": "sum(rate(cliproxy_provider_requests_total[5m])) by (provider)"
+ }
+ ]
+ },
+ {
+ "title": "Active Connections",
+ "type": "singlestat",
+ "targets": [
+ {
+ "expr": "cliproxy_active_connections"
+ }
+ ]
+ }
+ ]
+ }
+}
+```
+
+## Success Metrics
+
+- [ ] Build time < 5 minutes
+- [ ] Test coverage > 70%
+- [ ] Zero-downtime deployments
+- [ ] Automated rollback capability
+- [ ] Full observability (logs, metrics, traces)
+- [ ] Infrastructure as Code coverage 100%
+- [ ] Security scanning in CI
+- [ ] Multi-arch container images
+
+## Deployment Flow
+
+```
+1. Developer pushes code
+ ↓
+2. CI: Lint, Test, Build
+ ↓
+3. Docker image built (multi-arch)
+ ↓
+4. Security scan (Trivy)
+ ↓
+5. Push to registry
+ ↓
+6. Deploy to staging (auto)
+ ↓
+7. Integration tests
+ ↓
+8. Deploy to production (manual approval)
+ ↓
+9. Smoke tests
+ ↓
+10. Monitor and alert
+```
diff --git a/plans/clean-architecture-migration.md b/plans/clean-architecture-migration.md
new file mode 100644
index 0000000000000000000000000000000000000000..37a4c4fe9f18fa906e24a54ec0c19ad615bd8d1a
--- /dev/null
+++ b/plans/clean-architecture-migration.md
@@ -0,0 +1,822 @@
+# Clean Architecture Migration Plan
+
+## Current Architecture Issues
+
+1. **Mixed Concerns**: Handlers contain HTTP, business logic, and persistence code
+2. **Tight Coupling**: Direct dependency on Gin, PostgreSQL, external APIs
+3. **No Clear Boundaries**: Domain logic scattered across packages
+4. **Testing Difficulty**: Hard to unit test without full infrastructure
+
+## Target Clean Architecture
+
+```
+┌─────────────────────────────────────────────────────────────┐
+│ External World │
+│ (Gin, PostgreSQL, Redis, OAuth APIs, AI APIs) │
+└──────────────────────┬──────────────────────────────────────┘
+ │
+┌──────────────────────▼──────────────────────────────────────┐
+│ Interface Adapters │
+│ ┌──────────────┐ ┌──────────────┐ ┌──────────────────┐ │
+│ │ HTTP API │ │ Repository │ │ External Service │ │
+│ │ Handlers │ │ Implement │ │ Clients │ │
+│ └──────────────┘ └──────────────┘ └──────────────────┘ │
+└──────────────────────┬──────────────────────────────────────┘
+ │
+┌──────────────────────▼──────────────────────────────────────┐
+│ Application Layer │
+│ ┌────────────────────────────────────────────────────────┐ │
+│ │ Use Cases (Services) │ │
+│ │ - Orchestrate domain objects │ │
+│ │ - Define application workflows │ │
+│ │ - Transaction boundaries │ │
+│ └────────────────────────────────────────────────────────┘ │
+└──────────────────────┬──────────────────────────────────────┘
+ │
+┌──────────────────────▼──────────────────────────────────────┐
+│ Domain Layer │
+│ ┌──────────────────┐ ┌──────────────┐ ┌──────────────┐ │
+│ │ Entities │ │ Value Objects│ │ Domain Events│ │
+│ │ (Auth, Config) │ │ (Token, ID) │ │ (AuthCreated)│ │
+│ └──────────────────┘ └──────────────┘ └──────────────┘ │
+│ ┌──────────────────┐ ┌──────────────────────────────────┐│
+│ │ Domain Services │ │ Repository Interfaces ││
+│ │ (TokenValidator) │ │ (ports - no implementation) ││
+│ └──────────────────┘ └──────────────────────────────────┘│
+└─────────────────────────────────────────────────────────────┘
+```
+
+## Phase 1: Domain Layer Extraction (Week 1-2)
+
+### 1.1 Domain Entities
+**File**: `internal/domain/entities/auth.go`
+
+```go
+package entities
+
+import (
+ "time"
+)
+
+// Auth represents an authenticated OAuth session
+type Auth struct {
+ ID AuthID
+ Provider Provider
+ Status AuthStatus
+ AccessToken Token
+ RefreshToken Token
+ ExpiresAt time.Time
+ CreatedAt time.Time
+ UpdatedAt time.Time
+ Metadata map[string]string
+}
+
+// AuthID is a value object
+type AuthID string
+
+func (id AuthID) String() string { return string(id) }
+
+// Provider represents supported OAuth providers
+type Provider string
+
+const (
+ ProviderGemini Provider = "gemini"
+ ProviderClaude Provider = "claude"
+ ProviderCodex Provider = "codex"
+ ProviderAntigravity Provider = "antigravity"
+ ProviderQwen Provider = "qwen"
+)
+
+// AuthStatus represents the authentication state
+type AuthStatus int
+
+const (
+ AuthStatusUnknown AuthStatus = iota
+ AuthStatusPending
+ AuthStatusActive
+ AuthStatusExpired
+ AuthStatusRevoked
+)
+
+func (s AuthStatus) String() string {
+ switch s {
+ case AuthStatusPending:
+ return "pending"
+ case AuthStatusActive:
+ return "active"
+ case AuthStatusExpired:
+ return "expired"
+ case AuthStatusRevoked:
+ return "revoked"
+ default:
+ return "unknown"
+ }
+}
+
+// Domain methods
+func (a *Auth) IsExpired() bool {
+ return time.Now().After(a.ExpiresAt)
+}
+
+func (a *Auth) CanRefresh() bool {
+ return a.Status == AuthStatusActive && a.RefreshToken != ""
+}
+
+func (a *Auth) Refresh(newToken Token, newExpiry time.Time) error {
+ if !a.CanRefresh() {
+ return domainerrors.New("auth cannot be refreshed")
+ }
+ a.AccessToken = newToken
+ a.ExpiresAt = newExpiry
+ a.UpdatedAt = time.Now()
+ return nil
+}
+
+func (a *Auth) Revoke() {
+ a.Status = AuthStatusRevoked
+ a.AccessToken = ""
+ a.RefreshToken = ""
+ a.UpdatedAt = time.Now()
+}
+```
+
+### 1.2 Repository Ports
+**File**: `internal/domain/ports/repositories.go`
+
+```go
+package ports
+
+import (
+ "context"
+
+ "github.com/echyai/cliproxyapi/internal/domain/entities"
+)
+
+// AuthRepository defines the interface for auth persistence
+type AuthRepository interface {
+ FindByID(ctx context.Context, id entities.AuthID) (*entities.Auth, error)
+ FindByProvider(ctx context.Context, provider entities.Provider) ([]*entities.Auth, error)
+ Save(ctx context.Context, auth *entities.Auth) error
+ Delete(ctx context.Context, id entities.AuthID) error
+ List(ctx context.Context, filter AuthFilter) ([]*entities.Auth, error)
+}
+
+// AuthFilter for querying auths
+type AuthFilter struct {
+ Provider *entities.Provider
+ Status *entities.AuthStatus
+ CreatedAfter *time.Time
+}
+
+// ConfigRepository defines the interface for config persistence
+type ConfigRepository interface {
+ Get(ctx context.Context) (*entities.Config, error)
+ Save(ctx context.Context, cfg *entities.Config) error
+ Watch(ctx context.Context) (<-chan entities.Config, error)
+}
+
+// TokenStore defines the interface for token storage
+type TokenStore interface {
+ Get(ctx context.Context, key string) (string, error)
+ Set(ctx context.Context, key string, value string, ttl time.Duration) error
+ Delete(ctx context.Context, key string) error
+}
+
+// UnitOfWork manages transactional operations
+type UnitOfWork interface {
+ Begin(ctx context.Context) (Context, error)
+ Commit(ctx context.Context) error
+ Rollback(ctx context.Context) error
+}
+
+// Context carries transaction context
+type Context struct {
+ context.Context
+ Tx interface{} // Driver-specific transaction handle
+}
+```
+
+### 1.3 Domain Services
+**File**: `internal/domain/services/token_validator.go`
+
+```go
+package services
+
+import (
+ "context"
+ "time"
+
+ "github.com/echyai/cliproxyapi/internal/domain/entities"
+ "github.com/echyai/cliproxyapi/internal/domain/ports"
+)
+
+// TokenValidator handles token validation and refresh logic
+type TokenValidator struct {
+ authRepo ports.AuthRepository
+ providerRepo ports.ProviderRepository
+ tokenStore ports.TokenStore
+}
+
+func NewTokenValidator(
+ authRepo ports.AuthRepository,
+ providerRepo ports.ProviderRepository,
+ tokenStore ports.TokenStore,
+) *TokenValidator {
+ return &TokenValidator{
+ authRepo: authRepo,
+ providerRepo: providerRepo,
+ tokenStore: tokenStore,
+ }
+}
+
+// ValidateAndRefresh checks token validity and refreshes if needed
+func (v *TokenValidator) ValidateAndRefresh(
+ ctx context.Context,
+ authID entities.AuthID,
+) (*entities.Auth, error) {
+ auth, err := v.authRepo.FindByID(ctx, authID)
+ if err != nil {
+ return nil, err
+ }
+
+ if auth == nil {
+ return nil, domainerrors.NewNotFound("auth", authID.String())
+ }
+
+ // Check if token needs refresh
+ if !auth.IsExpired() {
+ return auth, nil
+ }
+
+ if !auth.CanRefresh() {
+ return nil, domainerrors.NewUnauthorized("token expired and cannot be refreshed")
+ }
+
+ // Get provider for refresh
+ provider, err := v.providerRepo.GetOAuthProvider(ctx, auth.Provider)
+ if err != nil {
+ return nil, err
+ }
+
+ // Perform refresh
+ newToken, newExpiry, err := provider.RefreshToken(ctx, auth.RefreshToken)
+ if err != nil {
+ return nil, domainerrors.NewProviderError(auth.Provider, err)
+ }
+
+ // Update auth entity
+ if err := auth.Refresh(newToken, newExpiry); err != nil {
+ return nil, err
+ }
+
+ // Persist changes
+ if err := v.authRepo.Save(ctx, auth); err != nil {
+ return nil, err
+ }
+
+ return auth, nil
+}
+```
+
+## Phase 2: Application Layer (Week 2-3)
+
+### 2.1 Use Cases
+**File**: `internal/application/usecase/auth_usecase.go`
+
+```go
+package usecase
+
+import (
+ "context"
+
+ "github.com/echyai/cliproxyapi/internal/application/dto"
+ "github.com/echyai/cliproxyapi/internal/application/mapper"
+ "github.com/echyai/cliproxyapi/internal/domain/entities"
+ "github.com/echyai/cliproxyapi/internal/domain/ports"
+ "github.com/echyai/cliproxyapi/internal/domain/services"
+)
+
+// AuthUseCase orchestrates auth-related operations
+type AuthUseCase struct {
+ authRepo ports.AuthRepository
+ providerRepo ports.ProviderRepository
+ tokenStore ports.TokenStore
+ validator *services.TokenValidator
+ mapper *mapper.AuthMapper
+ eventBus ports.EventBus
+}
+
+func NewAuthUseCase(
+ authRepo ports.AuthRepository,
+ providerRepo ports.ProviderRepository,
+ tokenStore ports.TokenStore,
+ eventBus ports.EventBus,
+) *AuthUseCase {
+ return &AuthUseCase{
+ authRepo: authRepo,
+ providerRepo: providerRepo,
+ tokenStore: tokenStore,
+ validator: services.NewTokenValidator(authRepo, providerRepo, tokenStore),
+ mapper: mapper.NewAuthMapper(),
+ eventBus: eventBus,
+ }
+}
+
+// GetAuth retrieves an auth by ID with token validation
+func (uc *AuthUseCase) GetAuth(ctx context.Context, id string) (*dto.AuthDTO, error) {
+ authID := entities.AuthID(id)
+
+ auth, err := uc.validator.ValidateAndRefresh(ctx, authID)
+ if err != nil {
+ return nil, err
+ }
+
+ return uc.mapper.ToDTO(auth), nil
+}
+
+// CreateAuth initiates OAuth flow
+func (uc *AuthUseCase) CreateAuth(
+ ctx context.Context,
+ input dto.CreateAuthInput,
+) (*dto.AuthDTO, error) {
+ // Validate input
+ if err := input.Validate(); err != nil {
+ return nil, err
+ }
+
+ // Get OAuth provider
+ provider, err := uc.providerRepo.GetOAuthProvider(ctx, entities.Provider(input.Provider))
+ if err != nil {
+ return nil, err
+ }
+
+ // Generate OAuth URL
+ authURL, state, err := provider.GenerateAuthURL(ctx, input.RedirectURI)
+ if err != nil {
+ return nil, err
+ }
+
+ // Store pending state
+ pendingAuth := entities.NewPendingAuth(
+ entities.Provider(input.Provider),
+ state,
+ input.RedirectURI,
+ )
+
+ if err := uc.authRepo.Save(ctx, pendingAuth); err != nil {
+ return nil, err
+ }
+
+ // Publish event
+ uc.eventBus.Publish(ctx, events.AuthInitiated{
+ AuthID: pendingAuth.ID,
+ Provider: pendingAuth.Provider,
+ AuthURL: authURL,
+ })
+
+ return uc.mapper.ToDTO(pendingAuth), nil
+}
+
+// CompleteAuth finishes OAuth flow
+func (uc *AuthUseCase) CompleteAuth(
+ ctx context.Context,
+ input dto.CompleteAuthInput,
+) (*dto.AuthDTO, error) {
+ // Find pending auth by state
+ auth, err := uc.authRepo.FindByState(ctx, input.State)
+ if err != nil {
+ return nil, err
+ }
+
+ if auth == nil {
+ return nil, domainerrors.NewInvalidState("invalid or expired state")
+ }
+
+ // Exchange code for tokens
+ provider, err := uc.providerRepo.GetOAuthProvider(ctx, auth.Provider)
+ if err != nil {
+ return nil, err
+ }
+
+ tokens, err := provider.ExchangeCode(ctx, input.Code)
+ if err != nil {
+ return nil, domainerrors.NewProviderError(auth.Provider, err)
+ }
+
+ // Activate auth
+ auth.Activate(tokens.AccessToken, tokens.RefreshToken, tokens.ExpiresAt)
+
+ if err := uc.authRepo.Save(ctx, auth); err != nil {
+ return nil, err
+ }
+
+ // Publish event
+ uc.eventBus.Publish(ctx, events.AuthCompleted{
+ AuthID: auth.ID,
+ Provider: auth.Provider,
+ })
+
+ return uc.mapper.ToDTO(auth), nil
+}
+
+// ListAuths retrieves auths with filtering
+func (uc *AuthUseCase) ListAuths(
+ ctx context.Context,
+ filter dto.AuthFilter,
+) ([]*dto.AuthDTO, error) {
+ domainFilter := ports.AuthFilter{}
+ if filter.Provider != "" {
+ p := entities.Provider(filter.Provider)
+ domainFilter.Provider = &p
+ }
+
+ auths, err := uc.authRepo.List(ctx, domainFilter)
+ if err != nil {
+ return nil, err
+ }
+
+ return uc.mapper.ToDTOList(auths), nil
+}
+
+// DeleteAuth revokes and removes an auth
+func (uc *AuthUseCase) DeleteAuth(ctx context.Context, id string) error {
+ authID := entities.AuthID(id)
+
+ auth, err := uc.authRepo.FindByID(ctx, authID)
+ if err != nil {
+ return err
+ }
+
+ if auth == nil {
+ return domainerrors.NewNotFound("auth", id)
+ }
+
+ // Revoke with provider if active
+ if auth.Status == entities.AuthStatusActive {
+ provider, err := uc.providerRepo.GetOAuthProvider(ctx, auth.Provider)
+ if err != nil {
+ return err
+ }
+
+ if err := provider.RevokeToken(ctx, auth.AccessToken); err != nil {
+ // Log but continue
+ }
+ }
+
+ auth.Revoke()
+
+ if err := uc.authRepo.Save(ctx, auth); err != nil {
+ return err
+ }
+
+ uc.eventBus.Publish(ctx, events.AuthRevoked{
+ AuthID: auth.ID,
+ Provider: auth.Provider,
+ })
+
+ return nil
+}
+```
+
+### 2.2 DTOs
+**File**: `internal/application/dto/auth_dto.go`
+
+```go
+package dto
+
+import (
+ "time"
+)
+
+// AuthDTO represents auth data for API responses
+type AuthDTO struct {
+ ID string `json:"id"`
+ Provider string `json:"provider"`
+ Status string `json:"status"`
+ ExpiresAt *time.Time `json:"expires_at,omitempty"`
+ CreatedAt time.Time `json:"created_at"`
+ Metadata map[string]string `json:"metadata,omitempty"`
+}
+
+// CreateAuthInput for creating new auth
+type CreateAuthInput struct {
+ Provider string `json:"provider" validate:"required,oneof=gemini claude codex antigravity qwen"`
+ RedirectURI string `json:"redirect_uri" validate:"required,url"`
+}
+
+func (i CreateAuthInput) Validate() error {
+ return validator.New().Struct(i)
+}
+
+// CompleteAuthInput for completing OAuth
+type CompleteAuthInput struct {
+ Code string `json:"code" validate:"required"`
+ State string `json:"state" validate:"required"`
+}
+
+func (i CompleteAuthInput) Validate() error {
+ return validator.New().Struct(i)
+}
+
+// AuthFilter for listing auths
+type AuthFilter struct {
+ Provider string `json:"provider,omitempty"`
+ Status string `json:"status,omitempty"`
+}
+```
+
+### 2.3 Mappers
+**File**: `internal/application/mapper/auth_mapper.go`
+
+```go
+package mapper
+
+type AuthMapper struct{}
+
+func NewAuthMapper() *AuthMapper {
+ return &AuthMapper{}
+}
+
+func (m *AuthMapper) ToDTO(auth *entities.Auth) *dto.AuthDTO {
+ if auth == nil {
+ return nil
+ }
+
+ return &dto.AuthDTO{
+ ID: auth.ID.String(),
+ Provider: string(auth.Provider),
+ Status: auth.Status.String(),
+ ExpiresAt: nullableTime(auth.ExpiresAt),
+ CreatedAt: auth.CreatedAt,
+ Metadata: auth.Metadata,
+ }
+}
+
+func (m *AuthMapper) ToDTOList(auths []*entities.Auth) []*dto.AuthDTO {
+ result := make([]*dto.AuthDTO, len(auths))
+ for i, auth := range auths {
+ result[i] = m.ToDTO(auth)
+ }
+ return result
+}
+
+func nullableTime(t time.Time) *time.Time {
+ if t.IsZero() {
+ return nil
+ }
+ return &t
+}
+```
+
+## Phase 3: Infrastructure Layer (Week 3-4)
+
+### 3.1 PostgreSQL Repository
+**File**: `internal/infrastructure/persistence/postgres/auth_repository.go`
+
+```go
+package postgres
+
+import (
+ "context"
+ "database/sql"
+ "errors"
+
+ "github.com/jackc/pgx/v5"
+ "github.com/jackc/pgx/v5/pgxpool"
+
+ "github.com/echyai/cliproxyapi/internal/domain/entities"
+ "github.com/echyai/cliproxyapi/internal/domain/ports"
+)
+
+type AuthRepository struct {
+ db *pgxpool.Pool
+}
+
+func NewAuthRepository(db *pgxpool.Pool) *AuthRepository {
+ return &AuthRepository{db: db}
+}
+
+func (r *AuthRepository) FindByID(ctx context.Context, id entities.AuthID) (*entities.Auth, error) {
+ query := `
+ SELECT id, provider, status, access_token, refresh_token,
+ expires_at, created_at, updated_at, metadata
+ FROM auths
+ WHERE id = $1
+ `
+
+ var auth entities.Auth
+ var metadata []byte
+
+ err := r.db.QueryRow(ctx, query, id).Scan(
+ &auth.ID,
+ &auth.Provider,
+ &auth.Status,
+ &auth.AccessToken,
+ &auth.RefreshToken,
+ &auth.ExpiresAt,
+ &auth.CreatedAt,
+ &auth.UpdatedAt,
+ &metadata,
+ )
+
+ if errors.Is(err, pgx.ErrNoRows) {
+ return nil, nil
+ }
+ if err != nil {
+ return nil, err
+ }
+
+ // Unmarshal metadata JSON
+ if len(metadata) > 0 {
+ json.Unmarshal(metadata, &auth.Metadata)
+ }
+
+ return &auth, nil
+}
+
+func (r *AuthRepository) Save(ctx context.Context, auth *entities.Auth) error {
+ query := `
+ INSERT INTO auths (id, provider, status, access_token, refresh_token,
+ expires_at, created_at, updated_at, metadata)
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
+ ON CONFLICT (id) DO UPDATE SET
+ status = EXCLUDED.status,
+ access_token = EXCLUDED.access_token,
+ refresh_token = EXCLUDED.refresh_token,
+ expires_at = EXCLUDED.expires_at,
+ updated_at = EXCLUDED.updated_at,
+ metadata = EXCLUDED.metadata
+ `
+
+ metadata, _ := json.Marshal(auth.Metadata)
+
+ _, err := r.db.Exec(ctx, query,
+ auth.ID,
+ auth.Provider,
+ auth.Status,
+ auth.AccessToken,
+ auth.RefreshToken,
+ auth.ExpiresAt,
+ auth.CreatedAt,
+ auth.UpdatedAt,
+ metadata,
+ )
+
+ return err
+}
+
+// ... other methods
+```
+
+## Phase 4: Interface Adapters (Week 4-5)
+
+### 4.1 HTTP Handler (Thin)
+**File**: `internal/api/handlers/auth_handler.go`
+
+```go
+package handlers
+
+import (
+ "net/http"
+
+ "github.com/gin-gonic/gin"
+
+ "github.com/echyai/cliproxyapi/internal/application/dto"
+ "github.com/echyai/cliproxyapi/internal/application/usecase"
+)
+
+type AuthHandler struct {
+ usecase *usecase.AuthUseCase
+}
+
+func NewAuthHandler(uc *usecase.AuthUseCase) *AuthHandler {
+ return &AuthHandler{usecase: uc}
+}
+
+func (h *AuthHandler) Register(r *gin.RouterGroup) {
+ g := r.Group("/auths")
+ {
+ g.GET("", h.List)
+ g.GET("/:id", h.Get)
+ g.POST("", h.Create)
+ g.POST("/callback", h.Complete)
+ g.DELETE("/:id", h.Delete)
+ }
+}
+
+func (h *AuthHandler) Get(c *gin.Context) {
+ id := c.Param("id")
+
+ auth, err := h.usecase.GetAuth(c.Request.Context(), id)
+ if err != nil {
+ _ = c.Error(err)
+ return
+ }
+
+ c.JSON(http.StatusOK, Response{Data: auth})
+}
+
+func (h *AuthHandler) List(c *gin.Context) {
+ filter := dto.AuthFilter{
+ Provider: c.Query("provider"),
+ Status: c.Query("status"),
+ }
+
+ auths, err := h.usecase.ListAuths(c.Request.Context(), filter)
+ if err != nil {
+ _ = c.Error(err)
+ return
+ }
+
+ c.JSON(http.StatusOK, Response{Data: auths})
+}
+
+func (h *AuthHandler) Create(c *gin.Context) {
+ var input dto.CreateAuthInput
+ if err := c.ShouldBindJSON(&input); err != nil {
+ _ = c.Error(domainerrors.NewValidationError(err.Error()))
+ return
+ }
+
+ auth, err := h.usecase.CreateAuth(c.Request.Context(), input)
+ if err != nil {
+ _ = c.Error(err)
+ return
+ }
+
+ c.JSON(http.StatusCreated, Response{Data: auth})
+}
+
+func (h *AuthHandler) Complete(c *gin.Context) {
+ var input dto.CompleteAuthInput
+ if err := c.ShouldBindJSON(&input); err != nil {
+ _ = c.Error(domainerrors.NewValidationError(err.Error()))
+ return
+ }
+
+ auth, err := h.usecase.CompleteAuth(c.Request.Context(), input)
+ if err != nil {
+ _ = c.Error(err)
+ return
+ }
+
+ c.JSON(http.StatusOK, Response{Data: auth})
+}
+
+func (h *AuthHandler) Delete(c *gin.Context) {
+ id := c.Param("id")
+
+ if err := h.usecase.DeleteAuth(c.Request.Context(), id); err != nil {
+ _ = c.Error(err)
+ return
+ }
+
+ c.Status(http.StatusNoContent)
+}
+```
+
+### 4.2 Dependency Injection Wire
+**File**: `internal/wire/wire.go`
+
+```go
+//go:build wireinject
+// +build wireinject
+
+package wire
+
+import (
+ "github.com/google/wire"
+
+ "github.com/echyai/cliproxyapi/internal/api/handlers"
+ "github.com/echyai/cliproxyapi/internal/application/usecase"
+ "github.com/echyai/cliproxyapi/internal/infrastructure/persistence/postgres"
+)
+
+func InitializeAuthHandler(db *pgxpool.Pool) *handlers.AuthHandler {
+ wire.Build(
+ postgres.NewAuthRepository,
+ postgres.NewProviderRepository,
+ postgres.NewTokenStore,
+ usecase.NewAuthUseCase,
+ handlers.NewAuthHandler,
+ )
+ return nil
+}
+```
+
+## Migration Strategy
+
+1. **Week 1-2**: Create domain layer (entities, ports)
+2. **Week 2-3**: Build application layer (use cases, DTOs)
+3. **Week 3-4**: Implement infrastructure (repositories)
+4. **Week 4-5**: Create thin HTTP handlers
+5. **Week 6**: Replace old handlers gradually
+6. **Week 7**: Remove old code
+
+## Success Criteria
+
+- [ ] Domain logic has no external dependencies
+- [ ] Handlers are < 50 lines each
+- [ ] 100% unit test coverage for use cases
+- [ ] Easy to swap PostgreSQL with other storage
+- [ ] Clear dependency direction: Handler -> UseCase -> Domain
diff --git a/plans/code-quality-linting.md b/plans/code-quality-linting.md
new file mode 100644
index 0000000000000000000000000000000000000000..124367ea54ae903d1e7ba756ba95790ee321600d
--- /dev/null
+++ b/plans/code-quality-linting.md
@@ -0,0 +1,264 @@
+# Code Quality & Linting Implementation Plan
+
+## Overview
+Establish comprehensive linting and code quality standards to catch issues early and maintain consistency across the codebase.
+
+## Phase 1: golangci-lint Setup (Week 1)
+
+### 1.1 Configuration File
+**File**: `.golangci.yml`
+
+```yaml
+run:
+ timeout: 5m
+ go: '1.24'
+ skip-dirs:
+ - management-center
+ - kiro-gateway
+
+linters:
+ enable:
+ # Default
+ - errcheck
+ - gosimple
+ - govet
+ - ineffassign
+ - staticcheck
+ - unused
+ # Additional
+ - bodyclose
+ - dogsled
+ - dupl
+ - exhaustive
+ - goconst
+ - gocritic
+ - gofmt
+ - goimports
+ - gomnd
+ - goprintffuncname
+ - gosec
+ - misspell
+ - nakedret
+ - noctx
+ - nolintlint
+ - prealloc
+ - revive
+ - stylecheck
+ - unconvert
+ - unparam
+ - whitespace
+
+linters-settings:
+ gocritic:
+ enabled-tags:
+ - performance
+ - style
+ - experimental
+ disabled-checks:
+ - wrapperFunc
+ - dupImport
+
+ revive:
+ rules:
+ - name: unexported-return
+ disabled: false
+ - name: exported
+ disabled: false
+ - name: package-comments
+ disabled: true
+
+ gomnd:
+ settings:
+ mnd:
+ checks:
+ - argument
+ - case
+ - condition
+ - operation
+ - return
+ ignored-numbers:
+ - '0'
+ - '1'
+ - '2'
+ - '10'
+ - '60'
+ - '100'
+
+ dupl:
+ threshold: 100
+
+ gosec:
+ excludes:
+ - G104 # Audit errors not checked (handled by errcheck)
+
+issues:
+ exclude-rules:
+ # Exclude init() function warnings in translator registrations
+ - path: internal/translator/.*/init\.go
+ linters:
+ - gochecknoinits
+
+ # Exclude underscore variable warnings in test files
+ - path: _test\.go
+ linters:
+ - errcheck
+
+ # Exclude long function warnings in handlers (will refactor separately)
+ - path: internal/api/handlers/
+ linters:
+ - funlen
+ - gocognit
+
+ exclude-use-default: false
+ max-issues-per-linter: 0
+ max-same-issues: 0
+```
+
+### 1.2 Makefile Targets
+**File**: `Makefile` (add to existing)
+
+```makefile
+.PHONY: lint lint-fix lint-install
+
+lint-install:
+ go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest
+
+lint:
+ golangci-lint run ./...
+
+lint-fix:
+ golangci-lint run --fix ./...
+
+# Pre-commit hook
+lint-precommit:
+ @echo "#!/bin/sh" > .git/hooks/pre-commit
+ @echo 'golangci-lint run --fast ./...' >> .git/hooks/pre-commit
+ @chmod +x .git/hooks/pre-commit
+ @echo "Pre-commit hook installed"
+```
+
+### 1.3 Initial Cleanup Tasks
+
+- [ ] Fix all `goimports` issues (import ordering)
+- [ ] Fix `gofmt` formatting inconsistencies
+- [ ] Add missing error checks (`errcheck`)
+- [ ] Remove unused variables and imports (`unused`)
+- [ ] Fix `misspell` typos
+- [ ] Address `gosec` security warnings
+- [ ] Fix `staticcheck` issues
+
+## Phase 2: GitHub Actions Integration (Week 1)
+
+**File**: `.github/workflows/lint.yml`
+
+```yaml
+name: Lint
+
+on:
+ push:
+ branches: [main, develop]
+ pull_request:
+ branches: [main, develop]
+
+jobs:
+ golangci:
+ name: lint
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: actions/setup-go@v5
+ with:
+ go-version: '1.24'
+
+ - name: golangci-lint
+ uses: golangci/golangci-lint-action@v6
+ with:
+ version: latest
+ args: --timeout=5m
+ only-new-issues: true
+```
+
+## Phase 3: Python Linting (kiro-gateway)
+
+**File**: `kiro-gateway/.pylintrc` or `kiro-gateway/pyproject.toml`
+
+```toml
+[tool.ruff]
+target-version = "py311"
+line-length = 100
+
+[tool.ruff.lint]
+select = [
+ "E", # pycodestyle errors
+ "F", # Pyflakes
+ "I", # isort
+ "N", # pep8-naming
+ "W", # pycodestyle warnings
+ "UP", # pyupgrade
+ "B", # flake8-bugbear
+ "C4", # flake8-comprehensions
+ "SIM", # flake8-simplify
+]
+ignore = ["E501"] # Line too long (handled by formatter)
+
+[tool.ruff.lint.pydocstyle]
+convention = "google"
+
+[tool.mypy]
+python_version = "3.11"
+strict = true
+warn_return_any = true
+warn_unused_configs = true
+disallow_untyped_defs = true
+```
+
+## Phase 4: TypeScript/React Linting (management-center)
+
+**File**: `management-center/eslint.config.js`
+
+```javascript
+import js from '@eslint/js';
+import tsParser from '@typescript-eslint/parser';
+import tsPlugin from '@typescript-eslint/eslint-plugin';
+import reactHooks from 'eslint-plugin-react-hooks';
+import reactRefresh from 'eslint-plugin-react-refresh';
+
+export default [
+ js.configs.recommended,
+ {
+ files: ['**/*.{ts,tsx}'],
+ languageOptions: {
+ parser: tsParser,
+ parserOptions: {
+ project: './tsconfig.json',
+ },
+ },
+ plugins: {
+ '@typescript-eslint': tsPlugin,
+ 'react-hooks': reactHooks,
+ 'react-refresh': reactRefresh,
+ },
+ rules: {
+ ...tsPlugin.configs.recommended.rules,
+ ...tsPlugin.configs['recommended-requiring-type-checking'].rules,
+ ...reactHooks.configs.recommended.rules,
+ 'react-refresh/only-export-components': 'warn',
+ '@typescript-eslint/explicit-function-return-type': 'off',
+ '@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
+ },
+ },
+];
+```
+
+## Success Metrics
+
+- [ ] Zero linting errors in CI
+- [ ] All new code passes linting on PR
+- [ ] Pre-commit hooks installed for all contributors
+- [ ] Linting time < 2 minutes for full codebase
+
+## Estimated Effort
+
+- **Week 1**: Setup and initial cleanup (16 hours)
+- **Ongoing**: Maintenance as part of PR reviews
diff --git a/plans/config-refactoring.md b/plans/config-refactoring.md
new file mode 100644
index 0000000000000000000000000000000000000000..67a4456e1d79be1c1da2d546f09806f7e3e2eec3
--- /dev/null
+++ b/plans/config-refactoring.md
@@ -0,0 +1,612 @@
+# Configuration Management Refactoring Plan
+
+## Current State Analysis
+
+**File**: `internal/config/config.go` (1,767 lines)
+
+**Issues**:
+- Single file with all config structures
+- Mix of loading, validation, and migration logic
+- Complex nested structs with 326+ lines of YAML example
+- No clear separation between config sources (file, env, DB)
+- Migration logic scattered throughout
+- No structured validation (manual checks)
+
+## Target Architecture
+
+```
+internal/config/
+├── domain/
+│ ├── models.go # Core config structs (no logic)
+│ └── validation.go # Validation rules
+├── loader/
+│ ├── file.go # YAML/JSON file loading
+│ ├── env.go # Environment variable loading
+│ └── merge.go # Config merging logic
+├── provider/
+│ ├── interface.go # ConfigProvider interface
+│ ├── file.go # File-based provider
+│ ├── postgres.go # PostgreSQL provider
+│ ├── git.go # Git-based provider
+│ └── objectstore.go # S3/MinIO provider
+├── migrations/
+│ ├── registry.go # Migration registry
+│ ├── v1_to_v2.go # Specific migrations
+│ └── runner.go # Migration runner
+├── watcher/
+│ └── file.go # File watching for hot reload
+└── config.go # Public API (facade)
+```
+
+## Phase 1: Domain Models Extraction (Week 1)
+
+### 1.1 Core Models
+**File**: `internal/config/domain/models.go`
+
+```go
+package domain
+
+import (
+ "time"
+ "github.com/echyai/cliproxyapi/internal/domain/auth"
+)
+
+// Config is the root configuration
+type Config struct {
+ Version string `yaml:"version" json:"version" validate:"required,semver"`
+ Server Server `yaml:"server" json:"server" validate:"required"`
+ Providers Providers `yaml:"providers" json:"providers"`
+ Storage Storage `yaml:"storage" json:"storage" validate:"required"`
+ Logging Logging `yaml:"logging" json:"logging"`
+ Metrics Metrics `yaml:"metrics" json:"metrics"`
+}
+
+func (c *Config) Validate() error {
+ validate := validator.New()
+ validate.RegisterValidation("semver", validateSemver)
+ return validate.Struct(c)
+}
+
+// Server configuration
+type Server struct {
+ Host string `yaml:"host" json:"host" validate:"hostname|ip"`
+ Port int `yaml:"port" json:"port" validate:"required,min=1,max=65535"`
+ ReadTimeout time.Duration `yaml:"read_timeout" json:"read_timeout" validate:"min=1s"`
+ WriteTimeout time.Duration `yaml:"write_timeout" json:"write_timeout" validate:"min=1s"`
+ MaxHeaderBytes int `yaml:"max_header_bytes" json:"max_header_bytes" validate:"min=1024"`
+ TLS *TLSConfig `yaml:"tls,omitempty" json:"tls,omitempty"`
+}
+
+// TLS configuration
+type TLSConfig struct {
+ CertFile string `yaml:"cert_file" json:"cert_file" validate:"required,file"`
+ KeyFile string `yaml:"key_file" json:"key_file" validate:"required,file"`
+}
+
+// Providers configuration
+type Providers struct {
+ Gemini *GeminiConfig `yaml:"gemini,omitempty" json:"gemini,omitempty"`
+ Claude *ClaudeConfig `yaml:"claude,omitempty" json:"claude,omitempty"`
+ Codex *CodexConfig `yaml:"codex,omitempty" json:"codex,omitempty"`
+ Antigravity *AntigravityConfig `yaml:"antigravity,omitempty" json:"antigravity,omitempty"`
+ Qwen *QwenConfig `yaml:"qwen,omitempty" json:"qwen,omitempty"`
+ Vertex *VertexConfig `yaml:"vertex,omitempty" json:"vertex,omitempty"`
+}
+
+// Gemini configuration
+type GeminiConfig struct {
+ Enabled bool `yaml:"enabled" json:"enabled"`
+ APIKey string `yaml:"api_key,omitempty" json:"api_key,omitempty" validate:"omitempty,min=10"`
+ OAuth *OAuthConfig `yaml:"oauth,omitempty" json:"oauth,omitempty"`
+ RateLimit RateLimitConfig `yaml:"rate_limit" json:"rate_limit"`
+ Models []ModelConfig `yaml:"models" json:"models" validate:"dive"`
+}
+
+// OAuth configuration (shared)
+type OAuthConfig struct {
+ ClientID string `yaml:"client_id" validate:"required"`
+ ClientSecret string `yaml:"client_secret" validate:"required"`
+ RedirectURL string `yaml:"redirect_url" validate:"required,url"`
+ Scopes []string `yaml:"scopes" validate:"min=1"`
+}
+
+// Rate limit configuration
+type RateLimitConfig struct {
+ RequestsPerSecond float64 `yaml:"requests_per_second" validate:"min=0.1"`
+ Burst int `yaml:"burst" validate:"min=1"`
+ Cooldown time.Duration `yaml:"cooldown" validate:"min=0"`
+}
+
+// Model configuration
+type ModelConfig struct {
+ Name string `yaml:"name" validate:"required"`
+ DisplayName string `yaml:"display_name"`
+ MaxTokens int `yaml:"max_tokens" validate:"min=1"`
+ Enabled bool `yaml:"enabled"`
+}
+
+// Storage configuration
+type Storage struct {
+ Type StorageType `yaml:"type" validate:"required,oneof=file postgres git objectstore"`
+ File *FileStorage `yaml:"file,omitempty"`
+ Postgres *PostgresStorage `yaml:"postgres,omitempty"`
+ Git *GitStorage `yaml:"git,omitempty"`
+ Object *ObjectStorage `yaml:"object,omitempty"`
+}
+
+type StorageType string
+
+const (
+ StorageTypeFile StorageType = "file"
+ StorageTypePostgres StorageType = "postgres"
+ StorageTypeGit StorageType = "git"
+ StorageTypeObject StorageType = "objectstore"
+)
+
+// FileStorage configuration
+type FileStorage struct {
+ Path string `yaml:"path" validate:"required,dirpath"`
+}
+
+// PostgresStorage configuration
+type PostgresStorage struct {
+ Host string `yaml:"host" validate:"required"`
+ Port int `yaml:"port" validate:"min=1,max=65535"`
+ Database string `yaml:"database" validate:"required"`
+ Username string `yaml:"username" validate:"required"`
+ Password string `yaml:"password" validate:"required"`
+ SSLMode string `yaml:"ssl_mode" validate:"oneof=disable require verify-ca verify-full"`
+}
+
+// Logging configuration
+type Logging struct {
+ Level string `yaml:"level" validate:"oneof=debug info warn error fatal panic"`
+ Format string `yaml:"format" validate:"oneof=json text"`
+ Output string `yaml:"output" validate:"oneof=stdout stderr file"`
+ File string `yaml:"file,omitempty"`
+}
+
+// Metrics configuration
+type Metrics struct {
+ Enabled bool `yaml:"enabled"`
+ Path string `yaml:"path" validate:"startswith=/"`
+ Port int `yaml:"port" validate:"min=1,max=65535"`
+}
+
+// Provider-specific configs (Claude, Codex, etc. follow similar pattern)
+type ClaudeConfig struct {
+ Enabled bool `yaml:"enabled"`
+ APIKey string `yaml:"api_key,omitempty"`
+ OAuth *OAuthConfig `yaml:"oauth,omitempty"`
+ RateLimit RateLimitConfig `yaml:"rate_limit"`
+}
+
+// ... other provider configs
+```
+
+### 1.2 Validation Rules
+**File**: `internal/config/domain/validation.go`
+
+```go
+package domain
+
+import (
+ "github.com/go-playground/validator/v10"
+ "regexp"
+)
+
+var semverRegex = regexp.MustCompile(`^v?(\d+)\.(\d+)\.(\d+)(?:-([\da-zA-Z-]+(?:\.[\da-zA-Z-]+)*))?(?:\+([\da-zA-Z-]+(?:\.[\da-zA-Z-]+)*))?$`)
+
+func validateSemver(fl validator.FieldLevel) bool {
+ return semverRegex.MatchString(fl.Field().String())
+}
+
+// ConfigValidator handles validation with custom rules
+type ConfigValidator struct {
+ validate *validator.Validate
+}
+
+func NewValidator() *ConfigValidator {
+ v := validator.New()
+ v.RegisterValidation("semver", validateSemver)
+ return &ConfigValidator{validate: v}
+}
+
+func (cv *ConfigValidator) Validate(cfg *Config) error {
+ if err := cv.validate.Struct(cfg); err != nil {
+ return NewValidationError(err)
+ }
+
+ // Cross-field validations
+ if err := cv.validateStorage(cfg.Storage); err != nil {
+ return err
+ }
+
+ if err := cv.validateProviders(cfg.Providers); err != nil {
+ return err
+ }
+
+ return nil
+}
+
+func (cv *ConfigValidator) validateStorage(s Storage) error {
+ switch s.Type {
+ case StorageTypeFile:
+ if s.File == nil {
+ return ValidationError{Field: "storage.file", Message: "required when type is file"}
+ }
+ case StorageTypePostgres:
+ if s.Postgres == nil {
+ return ValidationError{Field: "storage.postgres", Message: "required when type is postgres"}
+ }
+ // ... other cases
+ }
+ return nil
+}
+
+func (cv *ConfigValidator) validateProviders(p Providers) error {
+ // At least one provider should be enabled
+ hasEnabled := false
+ // Check each provider...
+ if !hasEnabled {
+ return ValidationError{Message: "at least one provider must be enabled"}
+ }
+ return nil
+}
+```
+
+## Phase 2: Provider Interface (Week 2)
+
+### 2.1 Provider Interface
+**File**: `internal/config/provider/interface.go`
+
+```go
+package provider
+
+import (
+ "context"
+ "github.com/echyai/cliproxyapi/internal/config/domain"
+)
+
+// Provider loads and saves configuration
+type Provider interface {
+ // Load retrieves the current configuration
+ Load(ctx context.Context) (*domain.Config, error)
+
+ // Save persists the configuration
+ Save(ctx context.Context, cfg *domain.Config) error
+
+ // Watch returns a channel that receives config updates
+ Watch(ctx context.Context) (<-chan domain.Config, error)
+
+ // Close cleans up resources
+ Close() error
+}
+
+// ProviderFactory creates providers based on type
+type ProviderFactory struct {
+ // dependencies
+}
+
+func (f *ProviderFactory) Create(storage domain.Storage) (Provider, error) {
+ switch storage.Type {
+ case domain.StorageTypeFile:
+ return NewFileProvider(storage.File)
+ case domain.StorageTypePostgres:
+ return NewPostgresProvider(storage.Postgres)
+ case domain.StorageTypeGit:
+ return NewGitProvider(storage.Git)
+ case domain.StorageTypeObject:
+ return NewObjectProvider(storage.Object)
+ default:
+ return nil, fmt.Errorf("unknown storage type: %s", storage.Type)
+ }
+}
+```
+
+### 2.2 File Provider
+**File**: `internal/config/provider/file.go`
+
+```go
+package provider
+
+import (
+ "context"
+ "fmt"
+ "os"
+ "path/filepath"
+
+ "github.com/fsnotify/fsnotify"
+ "gopkg.in/yaml.v3"
+
+ "github.com/echyai/cliproxyapi/internal/config/domain"
+)
+
+type FileProvider struct {
+ path string
+ watcher *fsnotify.Watcher
+ validate *domain.ConfigValidator
+}
+
+func NewFileProvider(cfg *domain.FileStorage) (*FileProvider, error) {
+ absPath, err := filepath.Abs(cfg.Path)
+ if err != nil {
+ return nil, fmt.Errorf("invalid path: %w", err)
+ }
+
+ return &FileProvider{
+ path: absPath,
+ validate: domain.NewValidator(),
+ }, nil
+}
+
+func (p *FileProvider) Load(ctx context.Context) (*domain.Config, error) {
+ data, err := os.ReadFile(p.path)
+ if err != nil {
+ if os.IsNotExist(err) {
+ return nil, domain.ErrConfigNotFound
+ }
+ return nil, fmt.Errorf("read config: %w", err)
+ }
+
+ var cfg domain.Config
+ if err := yaml.Unmarshal(data, &cfg); err != nil {
+ return nil, fmt.Errorf("parse config: %w", err)
+ }
+
+ if err := p.validate.Validate(&cfg); err != nil {
+ return nil, fmt.Errorf("validate config: %w", err)
+ }
+
+ return &cfg, nil
+}
+
+func (p *FileProvider) Save(ctx context.Context, cfg *domain.Config) error {
+ if err := p.validate.Validate(cfg); err != nil {
+ return err
+ }
+
+ data, err := yaml.Marshal(cfg)
+ if err != nil {
+ return fmt.Errorf("marshal config: %w", err)
+ }
+
+ // Write atomically
+ tmpPath := p.path + ".tmp"
+ if err := os.WriteFile(tmpPath, data, 0644); err != nil {
+ return fmt.Errorf("write temp file: %w", err)
+ }
+
+ return os.Rename(tmpPath, p.path)
+}
+
+func (p *FileProvider) Watch(ctx context.Context) (<-chan domain.Config, error) {
+ watcher, err := fsnotify.NewWatcher()
+ if err != nil {
+ return nil, err
+ }
+
+ if err := watcher.Add(p.path); err != nil {
+ return nil, err
+ }
+
+ p.watcher = watcher
+ updates := make(chan domain.Config)
+
+ go func() {
+ defer close(updates)
+ defer watcher.Close()
+
+ for {
+ select {
+ case event, ok := <-watcher.Events:
+ if !ok {
+ return
+ }
+ if event.Op&fsnotify.Write == fsnotify.Write {
+ cfg, err := p.Load(ctx)
+ if err == nil {
+ updates <- *cfg
+ }
+ }
+ case err, ok := <-watcher.Errors:
+ if !ok {
+ return
+ }
+ // Log error
+ _ = err
+ case <-ctx.Done():
+ return
+ }
+ }
+ }()
+
+ return updates, nil
+}
+
+func (p *FileProvider) Close() error {
+ if p.watcher != nil {
+ return p.watcher.Close()
+ }
+ return nil
+}
+```
+
+## Phase 3: Migration System (Week 2)
+
+### 3.1 Migration Framework
+**File**: `internal/config/migrations/registry.go`
+
+```go
+package migrations
+
+import (
+ "fmt"
+
+ "github.com/echyai/cliproxyapi/internal/config/domain"
+)
+
+// Migration transforms config from one version to another
+type Migration interface {
+ FromVersion() string
+ ToVersion() string
+ Migrate(cfg map[string]interface{}) (map[string]interface{}, error)
+}
+
+// Registry holds all migrations
+type Registry struct {
+ migrations map[string]Migration
+}
+
+func NewRegistry() *Registry {
+ r := &Registry{migrations: make(map[string]Migration)}
+ r.registerDefaults()
+ return r
+}
+
+func (r *Registry) register(m Migration) {
+ key := fmt.Sprintf("%s->%s", m.FromVersion(), m.ToVersion())
+ r.migrations[key] = m
+}
+
+func (r *Registry) registerDefaults() {
+ r.register(&V1ToV2Migration{})
+ r.register(&V2ToV3Migration{})
+ // ... more migrations
+}
+
+// Runner executes migrations
+type Runner struct {
+ registry *Registry
+}
+
+func (r *Runner) Migrate(cfg map[string]interface{}, targetVersion string) (map[string]interface{}, error) {
+ currentVersion, _ := cfg["version"].(string)
+ if currentVersion == "" {
+ currentVersion = "1.0.0"
+ }
+
+ for currentVersion != targetVersion {
+ key := fmt.Sprintf("%s->%s", currentVersion, targetVersion)
+ migration, ok := r.registry.migrations[key]
+ if !ok {
+ // Try to find intermediate step
+ nextVersion := findNextVersion(currentVersion)
+ if nextVersion == "" {
+ return nil, fmt.Errorf("no migration path from %s to %s", currentVersion, targetVersion)
+ }
+ key = fmt.Sprintf("%s->%s", currentVersion, nextVersion)
+ migration = r.registry.migrations[key]
+ }
+
+ var err error
+ cfg, err = migration.Migrate(cfg)
+ if err != nil {
+ return nil, fmt.Errorf("migrate %s: %w", key, err)
+ }
+ currentVersion = migration.ToVersion()
+ }
+
+ return cfg, nil
+}
+```
+
+## Phase 4: Public API Facade (Week 3)
+
+**File**: `internal/config/config.go` (simplified facade)
+
+```go
+package config
+
+import (
+ "context"
+ "sync"
+
+ "github.com/echyai/cliproxyapi/internal/config/domain"
+ "github.com/echyai/cliproxyapi/internal/config/migrations"
+ "github.com/echyai/cliproxyapi/internal/config/provider"
+)
+
+// Manager is the public API for configuration
+type Manager struct {
+ provider provider.Provider
+ current *domain.Config
+ mu sync.RWMutex
+ validate *domain.ConfigValidator
+}
+
+func NewManager(storage domain.Storage) (*Manager, error) {
+ p, err := provider.NewFactory().Create(storage)
+ if err != nil {
+ return nil, err
+ }
+
+ return &Manager{
+ provider: p,
+ validate: domain.NewValidator(),
+ }, nil
+}
+
+func (m *Manager) Load(ctx context.Context) error {
+ cfg, err := m.provider.Load(ctx)
+ if err != nil {
+ return err
+ }
+
+ m.mu.Lock()
+ m.current = cfg
+ m.mu.Unlock()
+
+ return nil
+}
+
+func (m *Manager) Get() *domain.Config {
+ m.mu.RLock()
+ defer m.mu.RUnlock()
+ return m.current
+}
+
+func (m *Manager) Update(ctx context.Context, updater func(*domain.Config) error) error {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+
+ cfg := *m.current // Copy
+ if err := updater(&cfg); err != nil {
+ return err
+ }
+
+ if err := m.validate.Validate(&cfg); err != nil {
+ return err
+ }
+
+ if err := m.provider.Save(ctx, &cfg); err != nil {
+ return err
+ }
+
+ m.current = &cfg
+ return nil
+}
+
+func (m *Manager) Watch(ctx context.Context) (<-chan domain.Config, error) {
+ return m.provider.Watch(ctx)
+}
+
+func (m *Manager) Close() error {
+ return m.provider.Close()
+}
+```
+
+## Migration Steps
+
+1. **Week 1**: Create new structure alongside existing code
+2. **Week 2**: Implement providers and migrations
+3. **Week 3**: Switch to new config system, keep old as backup
+4. **Week 4**: Remove old config code after testing
+
+## Success Metrics
+
+- [ ] Config file split into logical packages
+- [ ] Validation using struct tags
+- [ ] All providers implement same interface
+- [ ] Hot reload works for all storage types
+- [ ] Migrations tested and documented
diff --git a/plans/error-handling-refactor.md b/plans/error-handling-refactor.md
new file mode 100644
index 0000000000000000000000000000000000000000..4deca14e3e09f63fe6b171968fcd3b8634d4f01a
--- /dev/null
+++ b/plans/error-handling-refactor.md
@@ -0,0 +1,403 @@
+# Error Handling Standardization Plan
+
+## Current State Analysis
+
+**Issues Identified**:
+- Inconsistent `gin.H{"error": ...}` scattered across handlers
+- No clear separation between internal and user-facing errors
+- HTTP status codes set manually in each handler
+- Error messages exposed directly to clients
+- No structured error logging with context
+
+## Target Architecture
+
+```
+┌─────────────────────────────────────────────────────────────┐
+│ HTTP Transport Layer │
+│ (Gin Handlers - no error logic) │
+└──────────────────────┬──────────────────────────────────────┘
+ │
+ ▼
+┌─────────────────────────────────────────────────────────────┐
+│ Application Use Cases │
+│ (Return domain errors, no HTTP knowledge) │
+└──────────────────────┬──────────────────────────────────────┘
+ │
+ ▼
+┌─────────────────────────────────────────────────────────────┐
+│ Domain Services │
+│ (Business logic, domain errors) │
+└──────────────────────┬──────────────────────────────────────┘
+ │
+ ▼
+┌─────────────────────────────────────────────────────────────┐
+│ Infrastructure (DB, External APIs) │
+│ (Wrap external errors to domain) │
+└─────────────────────────────────────────────────────────────┘
+```
+
+## Phase 1: Domain Error Types (Week 1)
+
+### 1.1 Core Error Types
+**File**: `internal/domain/errors/errors.go`
+
+```go
+package errors
+
+import "fmt"
+
+type ErrorCode string
+
+const (
+ // Authentication errors
+ ErrCodeInvalidCredentials ErrorCode = "AUTH_INVALID_CREDENTIALS"
+ ErrCodeTokenExpired ErrorCode = "AUTH_TOKEN_EXPIRED"
+ ErrCodeTokenInvalid ErrorCode = "AUTH_TOKEN_INVALID"
+ ErrCodeUnauthorized ErrorCode = "AUTH_UNAUTHORIZED"
+
+ // Configuration errors
+ ErrCodeConfigNotFound ErrorCode = "CONFIG_NOT_FOUND"
+ ErrCodeConfigInvalid ErrorCode = "CONFIG_INVALID"
+ ErrCodeConfigValidation ErrorCode = "CONFIG_VALIDATION_FAILED"
+
+ // Provider errors
+ ErrCodeProviderNotFound ErrorCode = "PROVIDER_NOT_FOUND"
+ ErrCodeProviderUnavailable ErrorCode = "PROVIDER_UNAVAILABLE"
+ ErrCodeProviderRateLimited ErrorCode = "PROVIDER_RATE_LIMITED"
+
+ // Request errors
+ ErrCodeInvalidRequest ErrorCode = "REQUEST_INVALID"
+ ErrCodeMissingField ErrorCode = "REQUEST_MISSING_FIELD"
+ ErrCodeInvalidFormat ErrorCode = "REQUEST_INVALID_FORMAT"
+
+ // Storage errors
+ ErrCodeStorageFailure ErrorCode = "STORAGE_FAILURE"
+ ErrCodeNotFound ErrorCode = "RESOURCE_NOT_FOUND"
+ ErrCodeConflict ErrorCode = "RESOURCE_CONFLICT"
+
+ // Internal errors
+ ErrCodeInternal ErrorCode = "INTERNAL_ERROR"
+ ErrCodeNotImplemented ErrorCode = "NOT_IMPLEMENTED"
+)
+
+// DomainError is the base error type for the application
+type DomainError struct {
+ Code ErrorCode
+ Message string
+ Details map[string]interface{}
+ Cause error
+ HTTPStatus int
+}
+
+func (e *DomainError) Error() string {
+ if e.Cause != nil {
+ return fmt.Sprintf("%s: %s (caused by: %v)", e.Code, e.Message, e.Cause)
+ }
+ return fmt.Sprintf("%s: %s", e.Code, e.Message)
+}
+
+func (e *DomainError) Unwrap() error {
+ return e.Cause
+}
+
+// Error constructors for common cases
+func NewInvalidCredentials(msg string) *DomainError {
+ return &DomainError{
+ Code: ErrCodeInvalidCredentials,
+ Message: msg,
+ HTTPStatus: 401,
+ }
+}
+
+func NewConfigNotFound(resource string) *DomainError {
+ return &DomainError{
+ Code: ErrCodeConfigNotFound,
+ Message: fmt.Sprintf("configuration not found: %s", resource),
+ HTTPStatus: 404,
+ Details: map[string]interface{}{"resource": resource},
+ }
+}
+
+func NewProviderUnavailable(provider string, cause error) *DomainError {
+ return &DomainError{
+ Code: ErrCodeProviderUnavailable,
+ Message: fmt.Sprintf("provider %s is unavailable", provider),
+ HTTPStatus: 503,
+ Cause: cause,
+ Details: map[string]interface{}{"provider": provider},
+ }
+}
+
+func NewValidationError(field string, msg string) *DomainError {
+ return &DomainError{
+ Code: ErrCodeConfigValidation,
+ Message: fmt.Sprintf("validation failed for %s: %s", field, msg),
+ HTTPStatus: 400,
+ Details: map[string]interface{}{"field": field},
+ }
+}
+
+func NewInternalError(cause error) *DomainError {
+ return &DomainError{
+ Code: ErrCodeInternal,
+ Message: "an internal error occurred",
+ HTTPStatus: 500,
+ Cause: cause,
+ }
+}
+```
+
+### 1.2 Error Response DTO
+**File**: `internal/application/dto/error.go`
+
+```go
+package dto
+
+// ErrorResponse is the standardized error response for clients
+type ErrorResponse struct {
+ Success bool `json:"success" example:"false"`
+ Error ErrorInfo `json:"error"`
+ RequestID string `json:"request_id,omitempty"`
+}
+
+type ErrorInfo struct {
+ Code string `json:"code" example:"AUTH_INVALID_CREDENTIALS"`
+ Message string `json:"message" example:"Invalid credentials provided"`
+ Details map[string]interface{} `json:"details,omitempty"`
+}
+
+// InternalErrorResponse is returned for 500 errors (no sensitive info)
+type InternalErrorResponse struct {
+ Success bool `json:"success"`
+ Error string `json:"error"`
+ RequestID string `json:"request_id"`
+}
+```
+
+## Phase 2: Error Middleware (Week 1)
+
+### 2.1 Error Handler Middleware
+**File**: `internal/api/middleware/error_handler.go`
+
+```go
+package middleware
+
+import (
+ "net/http"
+
+ "github.com/gin-gonic/gin"
+ "github.com/sirupsen/logrus"
+
+ domainerrors "github.com/echyai/cliproxyapi/internal/domain/errors"
+ "github.com/echyai/cliproxyapi/internal/application/dto"
+)
+
+// ErrorHandler returns a middleware that handles domain errors
+func ErrorHandler(logger *logrus.Logger) gin.HandlerFunc {
+ return func(c *gin.Context) {
+ c.Next()
+
+ // Check if there are any errors
+ if len(c.Errors) == 0 {
+ return
+ }
+
+ // Get the last error
+ err := c.Errors.Last().Err
+ requestID := c.GetString("request_id")
+
+ // Handle domain errors
+ if domainErr, ok := err.(*domainerrors.DomainError); ok {
+ handleDomainError(c, domainErr, requestID, logger)
+ return
+ }
+
+ // Handle wrapped domain errors
+ var domainErr *domainerrors.DomainError
+ if errors.As(err, &domainErr) {
+ handleDomainError(c, domainErr, requestID, logger)
+ return
+ }
+
+ // Unknown error - log full details but return generic message
+ logger.WithError(err).
+ WithField("request_id", requestID).
+ WithField("path", c.Request.URL.Path).
+ Error("Unhandled error")
+
+ c.JSON(http.StatusInternalServerError, dto.InternalErrorResponse{
+ Success: false,
+ Error: "An unexpected error occurred",
+ RequestID: requestID,
+ })
+ }
+}
+
+func handleDomainError(c *gin.Context, err *domainerrors.DomainError, requestID string, logger *logrus.Logger) {
+ // Log with appropriate level based on status code
+ entry := logger.WithError(err).
+ WithField("request_id", requestID).
+ WithField("error_code", err.Code).
+ WithField("path", c.Request.URL.Path)
+
+ if err.HTTPStatus >= 500 {
+ entry.Error("Server error")
+ } else if err.HTTPStatus >= 400 {
+ entry.Warn("Client error")
+ }
+
+ // Return structured error response
+ c.JSON(err.HTTPStatus, dto.ErrorResponse{
+ Success: false,
+ Error: dto.ErrorInfo{
+ Code: string(err.Code),
+ Message: err.Message,
+ Details: err.Details,
+ },
+ RequestID: requestID,
+ })
+}
+```
+
+### 2.2 Request ID Middleware
+**File**: `internal/api/middleware/request_id.go`
+
+```go
+package middleware
+
+import (
+ "github.com/gin-gonic/gin"
+ "github.com/google/uuid"
+)
+
+func RequestID() gin.HandlerFunc {
+ return func(c *gin.Context) {
+ requestID := c.GetHeader("X-Request-ID")
+ if requestID == "" {
+ requestID = uuid.New().String()
+ }
+ c.Set("request_id", requestID)
+ c.Header("X-Request-ID", requestID)
+ c.Next()
+ }
+}
+```
+
+## Phase 3: Handler Refactoring Example (Week 2)
+
+### Before (Current)
+**File**: Example from auth handlers
+
+```go
+func (h *Handler) GetAuth(c *gin.Context) {
+ id := c.Param("id")
+ auth, err := h.store.GetAuth(id)
+ if err != nil {
+ c.JSON(500, gin.H{"error": err.Error()})
+ return
+ }
+ if auth == nil {
+ c.JSON(404, gin.H{"error": "not found"})
+ return
+ }
+ c.JSON(200, auth)
+}
+```
+
+### After (Refactored)
+
+```go
+func (h *Handler) GetAuth(c *gin.Context) {
+ id := c.Param("id")
+
+ auth, err := h.usecase.GetAuth(c.Request.Context(), id)
+ if err != nil {
+ _ = c.Error(err) // Pass to error middleware
+ return
+ }
+
+ c.JSON(200, dto.SuccessResponse{Data: auth})
+}
+```
+
+### Use Case Implementation
+
+```go
+func (uc *AuthUseCase) GetAuth(ctx context.Context, id string) (*dto.AuthDTO, error) {
+ if id == "" {
+ return nil, domainerrors.NewValidationError("id", "cannot be empty")
+ }
+
+ auth, err := uc.repo.FindByID(ctx, id)
+ if err != nil {
+ return nil, domainerrors.NewInternalError(err)
+ }
+
+ if auth == nil {
+ return nil, domainerrors.NewConfigNotFound(id)
+ }
+
+ return uc.mapper.ToDTO(auth), nil
+}
+```
+
+## Phase 4: Migration Strategy
+
+### Step-by-Step Migration
+
+1. **Week 1**: Create error types and middleware (new code only)
+2. **Week 2-3**: Migrate handlers one package at a time:
+ - [ ] `internal/api/handlers/management/auth_files.go`
+ - [ ] `internal/api/handlers/management/config_lists.go`
+ - [ ] `internal/api/handlers/proxy/`
+ - [ ] `internal/api/handlers/oauth/`
+3. **Week 4**: Remove old error handling patterns
+4. **Week 5**: Add error logging aggregation
+
+### Compatibility Layer (Temporary)
+
+```go
+// Deprecated: Use c.Error(err) instead
+func LegacyErrorResponse(c *gin.Context, status int, err error) {
+ domainErr := &domainerrors.DomainError{
+ Code: domainerrors.ErrorCode(fmt.Sprintf("LEGACY_%d", status)),
+ Message: err.Error(),
+ HTTPStatus: status,
+ }
+ _ = c.Error(domainErr)
+}
+```
+
+## Testing
+
+### Unit Tests for Error Handling
+**File**: `internal/api/middleware/error_handler_test.go`
+
+```go
+func TestErrorHandler_DomainError(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+ w := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(w)
+
+ // Simulate error
+ c.Error(domainerrors.NewConfigNotFound("test-config"))
+
+ // Run middleware
+ middleware := ErrorHandler(logrus.New())
+ middleware(c)
+
+ assert.Equal(t, 404, w.Code)
+
+ var resp dto.ErrorResponse
+ json.Unmarshal(w.Body.Bytes(), &resp)
+ assert.Equal(t, "CONFIG_NOT_FOUND", resp.Error.Code)
+}
+```
+
+## Success Metrics
+
+- [ ] All handlers use `c.Error()` instead of direct `c.JSON()` for errors
+- [ ] Zero `gin.H{"error": ...}` patterns in codebase
+- [ ] Consistent error response format across all APIs
+- [ ] Internal errors don't leak sensitive details
+- [ ] Request ID tracking for all error responses
diff --git a/plans/performance-agentic-roadmap.md b/plans/performance-agentic-roadmap.md
new file mode 100644
index 0000000000000000000000000000000000000000..271dc7f7c443365d57ed0d08b914ef7625d0beb7
--- /dev/null
+++ b/plans/performance-agentic-roadmap.md
@@ -0,0 +1,74 @@
+# Performance & Agentic Roadmap for CLIProxyAPI
+
+This roadmap focuses on optimizing the server for high-performance single-user usage (low latency, high throughput) and enhancing agentic capabilities (tool use, reasoning, debugging).
+
+## Phase 1: Core Performance Optimization
+
+### 1.1 HTTP Client Reuse (Critical)
+**Problem:** Currently, handlers create a new `http.Client` for every request. This disables TCP connection pooling (Keep-Alive), causing unnecessary TLS handshakes and increasing latency.
+**Solution:**
+- Create a global `*http.Client` in `main.go` with optimized transport settings.
+- Inject this client into all handlers.
+
+```go
+// Recommended Transport Settings
+t := &http.Transport{
+ MaxIdleConns: 100,
+ MaxIdleConnsPerHost: 20,
+ IdleConnTimeout: 90 * time.Second,
+ DisableCompression: true, // Proxy should often pass through raw bytes
+}
+```
+
+### 1.2 Memory Optimization (sync.Pool)
+**Problem:** JSON translation involves allocating new byte slices for every request/response body.
+**Solution:**
+- Implement `sync.Pool` for byte buffers used in the `translator` package.
+- Reuse buffers for reading request bodies and constructing responses.
+
+### 1.3 Asynchronous Logging
+**Problem:** Logging might be blocking the request path.
+**Solution:**
+- Ensure `logrus` or the custom logger is writing asynchronously or to a buffered channel to avoid I/O blocking on the main request thread.
+
+## Phase 2: Agentic Capabilities & Tooling
+
+### 2.1 Unified Tool Abstraction
+**Current State:** Tool translation is handled point-to-point (e.g., Gemini->OpenAI).
+**Goal:** Create a standardized `ToolDefinition` struct in `sdk` that acts as an intermediate representation (IR).
+**Benefit:**
+- Easier to add new providers (Ollama, DeepSeek, etc.).
+- Write tools once, run on any provider.
+
+### 2.2 "Agentic Trace" Debugging
+**Goal:** When using CLI tools (like Cline/RooCode), it's hard to see *why* a tool call failed.
+**Solution:**
+- Add a generic `X-Agent-Trace-ID` header.
+- Create a specific "Trace" log level that captures the *exact* JSON sent to and from the upstream provider for tool calls.
+- Expose a simple `/v1/trace/{id}` endpoint to view the "thought process" and tool outputs.
+
+### 2.3 Enhanced "Thinking" Support
+**Goal:** Maximize the reasoning capabilities of models like Claude 3.7 and OpenAI o1.
+**Actions:**
+- Ensure `internal/thinking` supports "Budget" parameters for all providers (currently seems focused on specific ones).
+- Add support for "Thought Blocks" parsing in the stream to separate "reasoning" from "final answer" for clients that don't support it natively.
+
+## Phase 3: Architecture & Maintainability
+
+### 3.1 Refactor `main.go`
+**Problem:** The entry point is too complex (`God Function`).
+**Solution:**
+- Extract server initialization into `internal/bootstrap`.
+- Move configuration loading to `internal/config/loader.go`.
+
+### 3.2 Security Hardening (Architectural)
+**Action:**
+- Externalize all hardcoded OAuth secrets to `config.yaml`.
+- Implement a simple "Allowlist" for the Management API's `APICall` to prevent SSRF, even in a private network (defense in depth).
+
+## Implementation Priority
+
+1. **Refactor HTTP Client** (Highest Impact/Effort ratio).
+2. **Refactor `main.go`** (Makes future changes easier).
+3. **Agentic Trace Logging** (High value for debugging "smart" agents).
+4. **Buffer Pools** (Micro-optimization, do last).
diff --git a/plans/performance-observability.md b/plans/performance-observability.md
new file mode 100644
index 0000000000000000000000000000000000000000..bddbe789ec8d5df3ff758283a7bdbbe85cf6390f
--- /dev/null
+++ b/plans/performance-observability.md
@@ -0,0 +1,866 @@
+# Performance & Observability Enhancement Plan
+
+## Current State Analysis
+
+**Observability**:
+- Basic logging with logrus
+- No structured logging standards
+- No distributed tracing
+- No metrics collection
+- No health check endpoints
+
+**Performance**:
+- No caching layer
+- No connection pooling for upstream APIs
+- No circuit breakers
+- No request coalescing
+- No rate limiting per API key
+
+## Phase 1: Structured Logging (Week 1)
+
+### 1.1 Migrate to slog (Go 1.21+)
+**File**: `internal/infrastructure/logging/logger.go`
+
+```go
+package logging
+
+import (
+ "context"
+ "log/slog"
+ "os"
+
+ "github.com/google/uuid"
+)
+
+// Logger wraps slog with application-specific methods
+type Logger struct {
+ *slog.Logger
+}
+
+// Config for logger
+type Config struct {
+ Level string `json:"level"`
+ Format string `json:"format"` // json or text
+ Output string `json:"output"` // stdout, stderr, or file path
+}
+
+func New(cfg Config) (*Logger, error) {
+ level := parseLevel(cfg.Level)
+
+ var handler slog.Handler
+ opts := &slog.HandlerOptions{
+ Level: level,
+ AddSource: true,
+ }
+
+ switch cfg.Format {
+ case "json":
+ handler = slog.NewJSONHandler(os.Stdout, opts)
+ case "text":
+ handler = slog.NewTextHandler(os.Stdout, opts)
+ default:
+ handler = slog.NewJSONHandler(os.Stdout, opts)
+ }
+
+ return &Logger{Logger: slog.New(handler)}, nil
+}
+
+// WithContext adds request context fields
+func (l *Logger) WithContext(ctx context.Context) *Logger {
+ return &Logger{
+ Logger: l.Logger.With(
+ "request_id", ctx.Value(RequestIDKey{}),
+ "trace_id", ctx.Value(TraceIDKey{}),
+ ),
+ }
+}
+
+// WithError adds error field
+func (l *Logger) WithError(err error) *Logger {
+ return &Logger{
+ Logger: l.Logger.With("error", err),
+ }
+}
+
+// WithField adds a single field
+func (l *Logger) WithField(key string, value interface{}) *Logger {
+ return &Logger{
+ Logger: l.Logger.With(key, value),
+ }
+}
+
+// WithFields adds multiple fields
+func (l *Logger) WithFields(fields map[string]interface{}) *Logger {
+ attrs := make([]slog.Attr, 0, len(fields))
+ for k, v := range fields {
+ attrs = append(attrs, slog.Any(k, v))
+ }
+ return &Logger{Logger: slog.New(l.Logger.Handler().WithAttrs(attrs))}
+}
+
+// Request logging helper
+func (l *Logger) LogRequest(
+ ctx context.Context,
+ method string,
+ path string,
+ status int,
+ duration time.Duration,
+ extra map[string]interface{},
+) {
+ fields := map[string]interface{}{
+ "http.method": method,
+ "http.path": path,
+ "http.status": status,
+ "duration_ms": duration.Milliseconds(),
+ "duration_bucket": durationBucket(duration),
+ }
+ for k, v := range extra {
+ fields[k] = v
+ }
+
+ logger := l.WithContext(ctx).WithFields(fields)
+
+ switch {
+ case status >= 500:
+ logger.Error("HTTP request completed with server error")
+ case status >= 400:
+ logger.Warn("HTTP request completed with client error")
+ case status >= 300:
+ logger.Info("HTTP request redirected")
+ default:
+ logger.Info("HTTP request completed")
+ }
+}
+
+func durationBucket(d time.Duration) string {
+ switch {
+ case d < 10*time.Millisecond:
+ return "<10ms"
+ case d < 50*time.Millisecond:
+ return "10-50ms"
+ case d < 100*time.Millisecond:
+ return "50-100ms"
+ case d < 500*time.Millisecond:
+ return "100-500ms"
+ case d < 1*time.Second:
+ return "500ms-1s"
+ default:
+ return ">1s"
+ }
+}
+```
+
+### 1.2 Gin Middleware
+**File**: `internal/api/middleware/logging.go`
+
+```go
+package middleware
+
+import (
+ "time"
+
+ "github.com/gin-gonic/gin"
+
+ "github.com/echyai/cliproxyapi/internal/infrastructure/logging"
+)
+
+func RequestLogger(logger *logging.Logger) gin.HandlerFunc {
+ return func(c *gin.Context) {
+ start := time.Now()
+
+ // Process request
+ c.Next()
+
+ // Log after request completes
+ duration := time.Since(start)
+
+ extra := map[string]interface{}{
+ "client_ip": c.ClientIP(),
+ "user_agent": c.Request.UserAgent(),
+ "errors": c.Errors.String(),
+ }
+
+ logger.LogRequest(
+ c.Request.Context(),
+ c.Request.Method,
+ c.Request.URL.Path,
+ c.Writer.Status(),
+ duration,
+ extra,
+ )
+ }
+}
+```
+
+## Phase 2: Distributed Tracing (Week 2)
+
+### 2.1 OpenTelemetry Setup
+**File**: `internal/infrastructure/tracing/tracing.go`
+
+```go
+package tracing
+
+import (
+ "context"
+
+ "go.opentelemetry.io/otel"
+ "go.opentelemetry.io/otel/exporters/jaeger"
+ "go.opentelemetry.io/otel/sdk/resource"
+ sdktrace "go.opentelemetry.io/otel/sdk/trace"
+ semconv "go.opentelemetry.io/otel/semconv/v1.24.0"
+ "go.opentelemetry.io/otel/trace"
+)
+
+var Tracer trace.Tracer
+
+func Init(serviceName, jaegerEndpoint string) (*sdktrace.TracerProvider, error) {
+ exp, err := jaeger.New(jaeger.WithCollectorEndpoint(
+ jaeger.WithEndpoint(jaegerEndpoint),
+ ))
+ if err != nil {
+ return nil, err
+ }
+
+ tp := sdktrace.NewTracerProvider(
+ sdktrace.WithBatcher(exp),
+ sdktrace.WithResource(resource.NewWithAttributes(
+ semconv.SchemaURL,
+ semconv.ServiceName(serviceName),
+ semconv.ServiceVersion("1.0.0"),
+ )),
+ )
+
+ otel.SetTracerProvider(tp)
+ Tracer = tp.Tracer(serviceName)
+
+ return tp, nil
+}
+
+// SpanFromContext retrieves current span
+func SpanFromContext(ctx context.Context) trace.Span {
+ return trace.SpanFromContext(ctx)
+}
+
+// StartSpan starts a new span
+func StartSpan(ctx context.Context, name string, opts ...trace.SpanStartOption) (context.Context, trace.Span) {
+ return Tracer.Start(ctx, name, opts...)
+}
+```
+
+### 2.2 Gin Tracing Middleware
+**File**: `internal/api/middleware/tracing.go`
+
+```go
+package middleware
+
+import (
+ "github.com/gin-gonic/gin"
+ "go.opentelemetry.io/otel/attribute"
+ "go.opentelemetry.io/otel/propagation"
+ semconv "go.opentelemetry.io/otel/semconv/v1.24.0"
+ "go.opentelemetry.io/otel/trace"
+
+ "github.com/echyai/cliproxyapi/internal/infrastructure/tracing"
+)
+
+func Tracing() gin.HandlerFunc {
+ return func(c *gin.Context) {
+ // Extract trace context from headers
+ ctx := propagation.TraceContext{}.Extract(c.Request.Context(),
+ propagation.HeaderCarrier(c.Request.Header))
+
+ // Start span
+ ctx, span := tracing.StartSpan(ctx,
+ c.Request.Method+" "+c.FullPath(),
+ trace.WithAttributes(
+ semconv.HTTPMethod(c.Request.Method),
+ semconv.HTTPURL(c.Request.URL.String()),
+ semconv.HTTPUserAgent(c.Request.UserAgent()),
+ semconv.HTTPClientIP(c.ClientIP()),
+ ),
+ )
+ defer span.End()
+
+ // Add span to context
+ c.Request = c.Request.WithContext(ctx)
+
+ // Continue
+ c.Next()
+
+ // Record result
+ span.SetAttributes(
+ semconv.HTTPStatusCode(c.Writer.Status()),
+ )
+
+ if c.Writer.Status() >= 400 {
+ span.RecordError(c.Errors.Last())
+ }
+ }
+}
+```
+
+## Phase 3: Metrics Collection (Week 2-3)
+
+### 3.1 Prometheus Metrics
+**File**: `internal/infrastructure/metrics/metrics.go`
+
+```go
+package metrics
+
+import (
+ "github.com/prometheus/client_golang/prometheus"
+ "github.com/prometheus/client_golang/prometheus/promauto"
+)
+
+var (
+ // HTTP metrics
+ HTTPRequestsTotal = promauto.NewCounterVec(
+ prometheus.CounterOpts{
+ Name: "cliproxy_http_requests_total",
+ Help: "Total HTTP requests",
+ },
+ []string{"method", "path", "status"},
+ )
+
+ HTTPRequestDuration = promauto.NewHistogramVec(
+ prometheus.HistogramOpts{
+ Name: "cliproxy_http_request_duration_seconds",
+ Help: "HTTP request duration in seconds",
+ Buckets: []float64{.005, .01, .025, .05, .1, .25, .5, 1, 2.5, 5, 10},
+ },
+ []string{"method", "path"},
+ )
+
+ // Provider metrics
+ ProviderRequestsTotal = promauto.NewCounterVec(
+ prometheus.CounterOpts{
+ Name: "cliproxy_provider_requests_total",
+ Help: "Total requests to upstream providers",
+ },
+ []string{"provider", "model", "status"},
+ )
+
+ ProviderRequestDuration = promauto.NewHistogramVec(
+ prometheus.HistogramOpts{
+ Name: "cliproxy_provider_request_duration_seconds",
+ Help: "Upstream provider request duration",
+ Buckets: []float64{.1, .25, .5, 1, 2.5, 5, 10, 30, 60},
+ },
+ []string{"provider", "model"},
+ )
+
+ ProviderTokensTotal = promauto.NewCounterVec(
+ prometheus.CounterOpts{
+ Name: "cliproxy_provider_tokens_total",
+ Help: "Total tokens processed",
+ },
+ []string{"provider", "model", "type"}, // type: input, output
+ )
+
+ // Rate limiting metrics
+ RateLimitHits = promauto.NewCounterVec(
+ prometheus.CounterOpts{
+ Name: "cliproxy_rate_limit_hits_total",
+ Help: "Total rate limit hits",
+ },
+ []string{"key_type", "key_id"},
+ )
+
+ // Cache metrics
+ CacheHits = promauto.NewCounterVec(
+ prometheus.CounterOpts{
+ Name: "cliproxy_cache_hits_total",
+ Help: "Total cache hits",
+ },
+ []string{"cache_name"},
+ )
+
+ CacheMisses = promauto.NewCounterVec(
+ prometheus.CounterOpts{
+ Name: "cliproxy_cache_misses_total",
+ Help: "Total cache misses",
+ },
+ []string{"cache_name"},
+ )
+
+ // Active connections
+ ActiveConnections = promauto.NewGauge(
+ prometheus.GaugeOpts{
+ Name: "cliproxy_active_connections",
+ Help: "Number of active connections",
+ },
+ )
+
+ // Auth metrics
+ ActiveAuths = promauto.NewGaugeVec(
+ prometheus.GaugeOpts{
+ Name: "cliproxy_active_auths",
+ Help: "Number of active authentications",
+ },
+ []string{"provider"},
+ )
+)
+```
+
+### 3.2 Gin Metrics Middleware
+**File**: `internal/api/middleware/metrics.go`
+
+```go
+package middleware
+
+import (
+ "strconv"
+ "time"
+
+ "github.com/gin-gonic/gin"
+ "github.com/prometheus/client_golang/prometheus"
+
+ "github.com/echyai/cliproxyapi/internal/infrastructure/metrics"
+)
+
+func PrometheusMetrics() gin.HandlerFunc {
+ return func(c *gin.Context) {
+ start := time.Now()
+
+ c.Next()
+
+ duration := time.Since(start).Seconds()
+ status := strconv.Itoa(c.Writer.Status())
+ path := c.FullPath()
+ if path == "" {
+ path = "unknown"
+ }
+
+ metrics.HTTPRequestsTotal.WithLabelValues(
+ c.Request.Method,
+ path,
+ status,
+ ).Inc()
+
+ metrics.HTTPRequestDuration.WithLabelValues(
+ c.Request.Method,
+ path,
+ ).Observe(duration)
+ }
+}
+```
+
+## Phase 4: Caching Layer (Week 3)
+
+### 4.1 Cache Interface
+**File**: `internal/infrastructure/cache/cache.go`
+
+```go
+package cache
+
+import (
+ "context"
+ "time"
+)
+
+// Cache interface for different backends
+type Cache interface {
+ Get(ctx context.Context, key string) ([]byte, error)
+ Set(ctx context.Context, key string, value []byte, ttl time.Duration) error
+ Delete(ctx context.Context, key string) error
+ GetSetMembers(ctx context.Context, key string) ([]string, error)
+ AddToSet(ctx context.Context, key string, members ...string) error
+ Close() error
+}
+
+// Config for cache
+type Config struct {
+ Type string // redis, inmemory
+ Redis *RedisConfig
+ Memory *MemoryConfig
+}
+
+type RedisConfig struct {
+ Addr string
+ Password string
+ DB int
+}
+
+type MemoryConfig struct {
+ MaxSize int // Maximum number of items
+}
+```
+
+### 4.2 Token Cache Implementation
+**File**: `internal/infrastructure/cache/token_cache.go`
+
+```go
+package cache
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "time"
+
+ "github.com/echyai/cliproxyapi/internal/domain/entities"
+)
+
+// TokenCache provides caching for OAuth tokens
+type TokenCache struct {
+ cache Cache
+ ttl time.Duration
+}
+
+func NewTokenCache(cache Cache, ttl time.Duration) *TokenCache {
+ return &TokenCache{
+ cache: cache,
+ ttl: ttl,
+ }
+}
+
+func (tc *TokenCache) Get(ctx context.Context, authID entities.AuthID) (*entities.TokenData, error) {
+ key := fmt.Sprintf("token:%s", authID)
+
+ data, err := tc.cache.Get(ctx, key)
+ if err != nil {
+ return nil, err
+ }
+ if data == nil {
+ return nil, nil
+ }
+
+ var token entities.TokenData
+ if err := json.Unmarshal(data, &token); err != nil {
+ return nil, err
+ }
+
+ return &token, nil
+}
+
+func (tc *TokenCache) Set(ctx context.Context, authID entities.AuthID, token *entities.TokenData) error {
+ key := fmt.Sprintf("token:%s", authID)
+
+ data, err := json.Marshal(token)
+ if err != nil {
+ return err
+ }
+
+ return tc.cache.Set(ctx, key, data, tc.ttl)
+}
+
+func (tc *TokenCache) Invalidate(ctx context.Context, authID entities.AuthID) error {
+ key := fmt.Sprintf("token:%s", authID)
+ return tc.cache.Delete(ctx, key)
+}
+```
+
+## Phase 5: Circuit Breaker (Week 4)
+
+### 5.1 Circuit Breaker Implementation
+**File**: `internal/infrastructure/resilience/circuit_breaker.go`
+
+```go
+package resilience
+
+import (
+ "context"
+ "errors"
+ "sync"
+ "time"
+)
+
+// State represents circuit breaker state
+type State int
+
+const (
+ StateClosed State = iota
+ StateOpen
+ StateHalfOpen
+)
+
+func (s State) String() string {
+ switch s {
+ case StateClosed:
+ return "closed"
+ case StateOpen:
+ return "open"
+ case StateHalfOpen:
+ return "half-open"
+ default:
+ return "unknown"
+ }
+}
+
+// Config for circuit breaker
+type Config struct {
+ FailureThreshold int // Number of failures before opening
+ SuccessThreshold int // Number of successes to close from half-open
+ Timeout time.Duration // Time before attempting half-open
+ HalfOpenMaxCalls int // Max calls allowed in half-open state
+}
+
+// CircuitBreaker implements the circuit breaker pattern
+type CircuitBreaker struct {
+ name string
+ config Config
+ state State
+ failures int
+ successes int
+ lastFailure time.Time
+ halfOpenCalls int
+ mu sync.RWMutex
+ onStateChange func(name string, from, to State)
+}
+
+func New(name string, config Config, onStateChange func(string, State, State)) *CircuitBreaker {
+ return &CircuitBreaker{
+ name: name,
+ config: config,
+ state: StateClosed,
+ onStateChange: onStateChange,
+ }
+}
+
+func (cb *CircuitBreaker) Execute(ctx context.Context, fn func() error) error {
+ cb.mu.Lock()
+ state := cb.state
+
+ switch state {
+ case StateOpen:
+ if time.Since(cb.lastFailure) > cb.config.Timeout {
+ cb.transitionTo(StateHalfOpen)
+ state = StateHalfOpen
+ } else {
+ cb.mu.Unlock()
+ return errors.New("circuit breaker is open")
+ }
+ case StateHalfOpen:
+ if cb.halfOpenCalls >= cb.config.HalfOpenMaxCalls {
+ cb.mu.Unlock()
+ return errors.New("circuit breaker half-open call limit reached")
+ }
+ cb.halfOpenCalls++
+ }
+ cb.mu.Unlock()
+
+ // Execute function
+ err := fn()
+
+ cb.recordResult(err)
+ return err
+}
+
+func (cb *CircuitBreaker) recordResult(err error) {
+ cb.mu.Lock()
+ defer cb.mu.Unlock()
+
+ if err == nil {
+ cb.onSuccess()
+ } else {
+ cb.onFailure()
+ }
+}
+
+func (cb *CircuitBreaker) onSuccess() {
+ switch cb.state {
+ case StateHalfOpen:
+ cb.successes++
+ if cb.successes >= cb.config.SuccessThreshold {
+ cb.transitionTo(StateClosed)
+ }
+ default:
+ cb.failures = 0
+ }
+}
+
+func (cb *CircuitBreaker) onFailure() {
+ cb.failures++
+ cb.lastFailure = time.Now()
+
+ switch cb.state {
+ case StateHalfOpen:
+ cb.transitionTo(StateOpen)
+ default:
+ if cb.failures >= cb.config.FailureThreshold {
+ cb.transitionTo(StateOpen)
+ }
+ }
+}
+
+func (cb *CircuitBreaker) transitionTo(newState State) {
+ if cb.state != newState {
+ oldState := cb.state
+ cb.state = newState
+ cb.failures = 0
+ cb.successes = 0
+ cb.halfOpenCalls = 0
+
+ if cb.onStateChange != nil {
+ cb.onStateChange(cb.name, oldState, newState)
+ }
+ }
+}
+
+func (cb *CircuitBreaker) State() State {
+ cb.mu.RLock()
+ defer cb.mu.RUnlock()
+ return cb.state
+}
+```
+
+### 5.2 Provider Client with Circuit Breaker
+**File**: `internal/infrastructure/providers/gemini_client.go`
+
+```go
+package providers
+
+import (
+ "context"
+ "fmt"
+ "time"
+
+ "github.com/echyai/cliproxyapi/internal/infrastructure/metrics"
+ "github.com/echyai/cliproxyapi/internal/infrastructure/resilience"
+)
+
+type GeminiClient struct {
+ baseURL string
+ apiKey string
+ httpClient *http.Client
+ circuitBreaker *resilience.CircuitBreaker
+ logger *logging.Logger
+}
+
+func NewGeminiClient(baseURL, apiKey string, logger *logging.Logger) *GeminiClient {
+ cb := resilience.New("gemini", resilience.Config{
+ FailureThreshold: 5,
+ SuccessThreshold: 2,
+ Timeout: 30 * time.Second,
+ HalfOpenMaxCalls: 3,
+ }, func(name string, from, to resilience.State) {
+ logger.WithFields(map[string]interface{}{
+ "circuit_breaker": name,
+ "from_state": from.String(),
+ "to_state": to.String(),
+ }).Warn("Circuit breaker state changed")
+ })
+
+ return &GeminiClient{
+ baseURL: baseURL,
+ apiKey: apiKey,
+ httpClient: &http.Client{
+ Timeout: 60 * time.Second,
+ Transport: &http.Transport{
+ MaxIdleConns: 100,
+ MaxIdleConnsPerHost: 10,
+ IdleConnTimeout: 90 * time.Second,
+ },
+ },
+ circuitBreaker: cb,
+ logger: logger,
+ }
+}
+
+func (c *GeminiClient) GenerateContent(
+ ctx context.Context,
+ req *GenerateContentRequest,
+) (*GenerateContentResponse, error) {
+ start := time.Now()
+ model := req.Model
+
+ var resp *GenerateContentResponse
+ var err error
+
+ cbErr := c.circuitBreaker.Execute(ctx, func() error {
+ resp, err = c.doGenerateContent(ctx, req)
+ return err
+ })
+
+ if cbErr != nil {
+ return nil, fmt.Errorf("circuit breaker: %w", cbErr)
+ }
+
+ // Record metrics
+ duration := time.Since(start).Seconds()
+ status := "success"
+ if err != nil {
+ status = "error"
+ }
+
+ metrics.ProviderRequestsTotal.WithLabelValues("gemini", model, status).Inc()
+ metrics.ProviderRequestDuration.WithLabelValues("gemini", model).Observe(duration)
+
+ if err != nil {
+ return nil, err
+ }
+
+ // Record token metrics
+ if resp.Usage != nil {
+ metrics.ProviderTokensTotal.WithLabelValues("gemini", model, "input").
+ Add(float64(resp.Usage.InputTokens))
+ metrics.ProviderTokensTotal.WithLabelValues("gemini", model, "output").
+ Add(float64(resp.Usage.OutputTokens))
+ }
+
+ return resp, nil
+}
+```
+
+## Phase 6: Health Checks (Week 4)
+
+**File**: `internal/api/handlers/health_handler.go`
+
+```go
+package handlers
+
+import (
+ "net/http"
+
+ "github.com/gin-gonic/gin"
+
+ "github.com/echyai/cliproxyapi/internal/infrastructure/health"
+)
+
+type HealthHandler struct {
+ checker *health.Checker
+}
+
+func NewHealthHandler(checker *health.Checker) *HealthHandler {
+ return &HealthHandler{checker: checker}
+}
+
+func (h *HealthHandler) Register(r *gin.RouterGroup) {
+ r.GET("/health", h.Liveness)
+ r.GET("/ready", h.Readiness)
+ r.GET("/metrics", gin.WrapH(promhttp.Handler()))
+}
+
+func (h *HealthHandler) Liveness(c *gin.Context) {
+ c.JSON(http.StatusOK, gin.H{
+ "status": "alive",
+ "time": time.Now().UTC(),
+ })
+}
+
+func (h *HealthHandler) Readiness(c *gin.Context) {
+ checks := h.checker.RunAll(c.Request.Context())
+
+ status := http.StatusOK
+ for _, check := range checks {
+ if !check.Healthy {
+ status = http.StatusServiceUnavailable
+ break
+ }
+ }
+
+ c.JSON(status, gin.H{
+ "status": map[bool]string{true: "ready", false: "not_ready"}[status == http.StatusOK],
+ "checks": checks,
+ "version": version.Get(),
+ })
+}
+```
+
+## Success Metrics
+
+- [ ] < 50ms p99 latency for cached token lookups
+- [ ] 99.9% availability measured via health checks
+- [ ] < 1% request error rate
+- [ ] All requests have distributed trace IDs
+- [ ] Dashboard with key metrics in Grafana
diff --git a/plans/security-hardening.md b/plans/security-hardening.md
new file mode 100644
index 0000000000000000000000000000000000000000..09501579c6545fac33d7e77bd507b641151c1df4
--- /dev/null
+++ b/plans/security-hardening.md
@@ -0,0 +1,635 @@
+# Security Hardening Plan
+
+## Current State Analysis
+
+**Issues Identified**:
+- No input validation middleware
+- API keys in plain text
+- No rate limiting per key
+- No audit logging
+- Secrets in configuration files
+- No CSP headers for management UI
+- Missing security headers
+- No SAST/DAST in CI
+
+## Phase 1: Input Validation & Sanitization (Week 1)
+
+### 1.1 Validation Middleware
+**File**: `internal/api/middleware/validation.go`
+
+```go
+package middleware
+
+import (
+ "bytes"
+ "io"
+ "net/http"
+ "strings"
+
+ "github.com/gin-gonic/gin"
+ "github.com/go-playground/validator/v10"
+)
+
+var validate = validator.New()
+
+// RequestValidator validates incoming requests
+type RequestValidator struct {
+ maxBodySize int64
+}
+
+func NewRequestValidator(maxBodySize int64) *RequestValidator {
+ return &RequestValidator{maxBodySize: maxBodySize}
+}
+
+func (v *RequestValidator) Validate() gin.HandlerFunc {
+ return func(c *gin.Context) {
+ // Check content type for POST/PUT/PATCH
+ if c.Request.Method != "GET" && c.Request.Method != "DELETE" {
+ contentType := c.ContentType()
+ if contentType != "application/json" {
+ c.AbortWithStatusJSON(http.StatusUnsupportedMediaType, gin.H{
+ "error": "Content-Type must be application/json",
+ })
+ return
+ }
+ }
+
+ // Limit body size
+ c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, v.maxBodySize)
+
+ c.Next()
+ }
+}
+
+// SanitizeHeaders removes sensitive headers from logging
+type SanitizedHeader string
+
+const (
+ Authorization SanitizedHeader = "Authorization"
+ Cookie SanitizedHeader = "Cookie"
+ SetCookie SanitizedHeader = "Set-Cookie"
+)
+
+func (v *RequestValidator) SanitizeHeaders(c *gin.Context) {
+ // Create copy of headers with sensitive data redacted
+ headers := make(http.Header)
+ for k, values := range c.Request.Header {
+ if strings.EqualFold(k, "Authorization") ||
+ strings.EqualFold(k, "Cookie") ||
+ strings.EqualFold(k, "X-API-Key") {
+ headers.Set(k, "[REDACTED]")
+ } else {
+ headers[k] = values
+ }
+ }
+ c.Set("sanitized_headers", headers)
+}
+```
+
+### 1.2 SQL Injection Prevention
+**File**: `internal/infrastructure/persistence/sanitize.go`
+
+```go
+package persistence
+
+import (
+ "regexp"
+ "strings"
+)
+
+var sqlInjectionPattern = regexp.MustCompile(`(?i)(union|select|insert|delete|update|drop|create|alter|exec|execute|;|--|/\*|\*/)`)
+
+// SanitizeIdentifier sanitizes database identifiers (table names, column names)
+func SanitizeIdentifier(identifier string) string {
+ // Remove any characters that aren't alphanumeric or underscore
+ clean := regexp.MustCompile(`[^a-zA-Z0-9_]`).ReplaceAllString(identifier, "")
+
+ // Check for SQL keywords
+ if sqlInjectionPattern.MatchString(clean) {
+ return ""
+ }
+
+ return clean
+}
+
+// ValidateOrderBy validates ORDER BY clause parameters
+func ValidateOrderBy(field, direction string, allowedFields []string) (string, error) {
+ // Validate field is in allowed list
+ validField := false
+ for _, f := range allowedFields {
+ if strings.EqualFold(f, field) {
+ validField = true
+ break
+ }
+ }
+
+ if !validField {
+ return "", fmt.Errorf("invalid sort field: %s", field)
+ }
+
+ // Validate direction
+ direction = strings.ToUpper(direction)
+ if direction != "ASC" && direction != "DESC" {
+ direction = "ASC"
+ }
+
+ return fmt.Sprintf("%s %s", SanitizeIdentifier(field), direction), nil
+}
+```
+
+## Phase 2: Rate Limiting (Skipped)
+
+*This phase has been skipped as per user request (single user environment).*
+
+## Phase 3: Secrets Management (Week 2)
+
+### 3.1 Secrets Provider Interface
+**File**: `internal/infrastructure/secrets/provider.go`
+
+```go
+package secrets
+
+import (
+ "context"
+ "fmt"
+)
+
+// Provider interface for secrets management
+type Provider interface {
+ Get(ctx context.Context, key string) (string, error)
+ GetJSON(ctx context.Context, key string, v interface{}) error
+ Close() error
+}
+
+// Config for secrets provider
+type Config struct {
+ Type string // vault, aws, gcp, azure, env
+ Vault *VaultConfig
+ AWS *AWSConfig
+ GCP *GCPConfig
+ Azure *AzureConfig
+}
+
+type VaultConfig struct {
+ Address string
+ Token string
+ Path string
+}
+
+type AWSConfig struct {
+ Region string
+ ARN string
+}
+```
+
+### 3.2 HashiCorp Vault Implementation
+**File**: `internal/infrastructure/secrets/vault.go`
+
+```go
+package secrets
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+
+ vault "github.com/hashicorp/vault/api"
+)
+
+type VaultProvider struct {
+ client *vault.Client
+ path string
+}
+
+func NewVaultProvider(cfg *VaultConfig) (*VaultProvider, error) {
+ config := vault.DefaultConfig()
+ config.Address = cfg.Address
+
+ client, err := vault.NewClient(config)
+ if err != nil {
+ return nil, fmt.Errorf("create vault client: %w", err)
+ }
+
+ client.SetToken(cfg.Token)
+
+ return &VaultProvider{
+ client: client,
+ path: cfg.Path,
+ }, nil
+}
+
+func (v *VaultProvider) Get(ctx context.Context, key string) (string, error) {
+ secret, err := v.client.KVv2(v.path).Get(ctx, key)
+ if err != nil {
+ return "", fmt.Errorf("get secret %s: %w", key, err)
+ }
+
+ value, ok := secret.Data["value"].(string)
+ if !ok {
+ return "", fmt.Errorf("secret %s has no string value", key)
+ }
+
+ return value, nil
+}
+
+func (v *VaultProvider) GetJSON(ctx context.Context, key string, v interface{}) error {
+ secret, err := v.client.KVv2(v.path).Get(ctx, key)
+ if err != nil {
+ return err
+ }
+
+ data, err := json.Marshal(secret.Data)
+ if err != nil {
+ return err
+ }
+
+ return json.Unmarshal(data, v)
+}
+
+func (v *VaultProvider) Close() error {
+ return nil
+}
+```
+
+### 3.3 Environment Variable Provider (Default)
+**File**: `internal/infrastructure/secrets/env.go`
+
+```go
+package secrets
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "os"
+ "strings"
+)
+
+// EnvProvider reads secrets from environment variables
+type EnvProvider struct {
+ prefix string
+}
+
+func NewEnvProvider(prefix string) *EnvProvider {
+ return &EnvProvider{prefix: prefix}
+}
+
+func (e *EnvProvider) Get(ctx context.Context, key string) (string, error) {
+ envKey := e.prefix + strings.ToUpper(strings.ReplaceAll(key, ".", "_"))
+ value := os.Getenv(envKey)
+ if value == "" {
+ return "", fmt.Errorf("environment variable %s not set", envKey)
+ }
+ return value, nil
+}
+
+func (e *EnvProvider) GetJSON(ctx context.Context, key string, v interface{}) error {
+ value, err := e.Get(ctx, key)
+ if err != nil {
+ return err
+ }
+ return json.Unmarshal([]byte(value), v)
+}
+
+func (e *EnvProvider) Close() error {
+ return nil
+}
+```
+
+## Phase 4: Security Headers (Week 2)
+
+### 4.1 Security Headers Middleware
+**File**: `internal/api/middleware/security.go`
+
+```go
+package middleware
+
+import (
+ "net/http"
+ "time"
+
+ "github.com/gin-gonic/gin"
+)
+
+func SecurityHeaders() gin.HandlerFunc {
+ return func(c *gin.Context) {
+ // Prevent clickjacking
+ c.Header("X-Frame-Options", "DENY")
+
+ // Prevent MIME type sniffing
+ c.Header("X-Content-Type-Options", "nosniff")
+
+ // XSS Protection
+ c.Header("X-XSS-Protection", "1; mode=block")
+
+ // Referrer Policy
+ c.Header("Referrer-Policy", "strict-origin-when-cross-origin")
+
+ // Permissions Policy
+ c.Header("Permissions-Policy", "geolocation=(), microphone=(), camera=()")
+
+ // HSTS (HTTPS only)
+ c.Header("Strict-Transport-Security", "max-age=31536000; includeSubDomains; preload")
+
+ // CSP for API (restrictive)
+ if !strings.HasPrefix(c.Request.URL.Path, "/management") {
+ c.Header("Content-Security-Policy", "default-src 'none'")
+ }
+
+ c.Next()
+ }
+}
+
+// CSP for Management UI
+func ManagementCSP() gin.HandlerFunc {
+ return func(c *gin.Context) {
+ csp := strings.Join([]string{
+ "default-src 'self'",
+ "script-src 'self' 'unsafe-inline' 'unsafe-eval'",
+ "style-src 'self' 'unsafe-inline'",
+ "img-src 'self' data: https:",
+ "font-src 'self'",
+ "connect-src 'self'",
+ "frame-ancestors 'none'",
+ "base-uri 'self'",
+ "form-action 'self'",
+ }, "; ")
+
+ c.Header("Content-Security-Policy", csp)
+ c.Next()
+ }
+}
+
+// CORS configuration
+func CORS(allowedOrigins []string) gin.HandlerFunc {
+ return func(c *gin.Context) {
+ origin := c.Request.Header.Get("Origin")
+
+ // Check if origin is allowed
+ allowed := false
+ for _, o := range allowedOrigins {
+ if o == "*" || o == origin {
+ allowed = true
+ break
+ }
+ }
+
+ if allowed {
+ c.Header("Access-Control-Allow-Origin", origin)
+ c.Header("Access-Control-Allow-Credentials", "true")
+ c.Header("Access-Control-Allow-Headers", "Content-Type, Authorization, X-API-Key")
+ c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
+ c.Header("Access-Control-Max-Age", "86400")
+ }
+
+ if c.Request.Method == "OPTIONS" {
+ c.AbortWithStatus(http.StatusNoContent)
+ return
+ }
+
+ c.Next()
+ }
+}
+```
+
+## Phase 5: Audit Logging (Week 3)
+
+### 5.1 Audit Logger
+**File**: `internal/infrastructure/audit/logger.go`
+
+```go
+package audit
+
+import (
+ "context"
+ "encoding/json"
+ "time"
+)
+
+// EventType represents types of audit events
+type EventType string
+
+const (
+ EventAuthCreated EventType = "auth.created"
+ EventAuthDeleted EventType = "auth.deleted"
+ EventAuthRefreshed EventType = "auth.refreshed"
+ EventConfigChanged EventType = "config.changed"
+ EventAPICall EventType = "api.call"
+ EventLoginSuccess EventType = "login.success"
+ EventLoginFailure EventType = "login.failure"
+ EventTokenRevoked EventType = "token.revoked"
+)
+
+// Severity level
+type Severity string
+
+const (
+ SeverityInfo Severity = "info"
+ SeverityWarning Severity = "warning"
+ SeverityCritical Severity = "critical"
+)
+
+// Event represents an audit log entry
+type Event struct {
+ Timestamp time.Time `json:"timestamp"`
+ Type EventType `json:"type"`
+ Severity Severity `json:"severity"`
+ Actor Actor `json:"actor"`
+ Resource Resource `json:"resource"`
+ Action string `json:"action"`
+ Result string `json:"result"` // success, failure
+ Details map[string]interface{} `json:"details,omitempty"`
+ RequestID string `json:"request_id"`
+ IP string `json:"ip"`
+ UserAgent string `json:"user_agent,omitempty"`
+}
+
+type Actor struct {
+ ID string `json:"id"`
+ Type string `json:"type"` // user, api_key, system
+}
+
+type Resource struct {
+ Type string `json:"type"`
+ ID string `json:"id"`
+}
+
+// Logger interface for audit events
+type Logger interface {
+ Log(ctx context.Context, event Event) error
+}
+
+// FileLogger writes audit events to file
+type FileLogger struct {
+ writer io.Writer
+ mu sync.Mutex
+}
+
+func NewFileLogger(path string) (*FileLogger, error) {
+ file, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600)
+ if err != nil {
+ return nil, err
+ }
+
+ return &FileLogger{writer: file}, nil
+}
+
+func (l *FileLogger) Log(ctx context.Context, event Event) error {
+ event.Timestamp = time.Now().UTC()
+
+ if reqID := ctx.Value("request_id"); reqID != nil {
+ event.RequestID = reqID.(string)
+ }
+
+ data, err := json.Marshal(event)
+ if err != nil {
+ return err
+ }
+
+ l.mu.Lock()
+ defer l.mu.Unlock()
+
+ _, err = fmt.Fprintf(l.writer, "%s\n", data)
+ return err
+}
+
+// Audit middleware
+func Middleware(logger Logger) gin.HandlerFunc {
+ return func(c *gin.Context) {
+ start := time.Now()
+
+ c.Next()
+
+ // Log API calls
+ if c.Request.URL.Path != "/health" && c.Request.URL.Path != "/metrics" {
+ severity := SeverityInfo
+ result := "success"
+
+ if c.Writer.Status() >= 500 {
+ severity = SeverityCritical
+ result = "failure"
+ } else if c.Writer.Status() >= 400 {
+ severity = SeverityWarning
+ result = "failure"
+ }
+
+ logger.Log(c.Request.Context(), Event{
+ Type: EventAPICall,
+ Severity: severity,
+ Actor: Actor{
+ ID: c.GetHeader("X-API-Key"),
+ Type: "api_key",
+ },
+ Resource: Resource{
+ Type: "endpoint",
+ ID: c.Request.Method + " " + c.FullPath(),
+ },
+ Action: c.Request.Method,
+ Result: result,
+ Details: map[string]interface{}{
+ "status_code": c.Writer.Status(),
+ "duration_ms": time.Since(start).Milliseconds(),
+ },
+ IP: c.ClientIP(),
+ UserAgent: c.Request.UserAgent(),
+ })
+ }
+ }
+}
+```
+
+## Phase 6: SAST/DAST Integration (Week 3)
+
+### 6.1 GitHub Actions Security Workflow
+**File**: `.github/workflows/security.yml`
+
+```yaml
+name: Security
+
+on:
+ push:
+ branches: [main, develop]
+ pull_request:
+ branches: [main, develop]
+ schedule:
+ - cron: '0 0 * * 0' # Weekly
+
+jobs:
+ gosec:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Run Gosec
+ uses: securego/gosec@master
+ with:
+ args: '-fmt sarif -out results.sarif ./...'
+
+ - name: Upload SARIF
+ uses: github/codeql-action/upload-sarif@v2
+ with:
+ sarif_file: results.sarif
+
+ govulncheck:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: actions/setup-go@v5
+ with:
+ go-version: '1.24'
+
+ - name: Install govulncheck
+ run: go install golang.org/x/vuln/cmd/govulncheck@latest
+
+ - name: Run govulncheck
+ run: govulncheck ./...
+
+ trivy:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Build image
+ run: docker build -t cliproxy:test .
+
+ - name: Run Trivy
+ uses: aquasecurity/trivy-action@master
+ with:
+ image-ref: 'cliproxy:test'
+ format: 'sarif'
+ output: 'trivy-results.sarif'
+
+ - name: Upload SARIF
+ uses: github/codeql-action/upload-sarif@v2
+ with:
+ sarif_file: trivy-results.sarif
+
+ dependency-review:
+ runs-on: ubuntu-latest
+ if: github.event_name == 'pull_request'
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/dependency-review-action@v3
+```
+
+## Success Metrics
+
+- [ ] All secrets externalized from config files
+- [ ] Zero high/critical vulnerabilities in SAST scans
+- [ ] Rate limiting on all public endpoints
+- [ ] Complete audit trail for auth events
+- [ ] Security headers on all responses
+- [ ] Input validation on all user inputs
+- [ ] Dependency vulnerabilities tracked and remediated
+
+## Security Checklist
+
+- [ ] API keys are hashed in storage
+- [ ] TLS 1.3 required for all connections
+- [ ] No secrets in logs
+- [ ] SQL injection prevention in all queries
+- [ ] XSS prevention in management UI
+- [ ] CSRF tokens for state-changing operations
+- [ ] Request size limits enforced
+- [ ] IP allowlisting for management endpoints
diff --git a/plans/testing-infrastructure.md b/plans/testing-infrastructure.md
new file mode 100644
index 0000000000000000000000000000000000000000..2b45e46d3674630f8747999f9a632848b384e347
--- /dev/null
+++ b/plans/testing-infrastructure.md
@@ -0,0 +1,591 @@
+# Testing Infrastructure Improvement Plan
+
+## Current State Analysis
+
+- **Total Go files**: 377
+- **Test files**: 50 (~13% coverage)
+- **Issues**:
+ - Low test coverage
+ - Heavy use of `init()` functions making tests hard to isolate
+ - External dependencies not mocked
+ - No contract tests between API formats
+ - Integration tests missing for storage backends
+
+## Target Metrics
+
+- **Unit test coverage**: 70%+
+- **Integration test coverage**: Key paths covered
+- **Test execution time**: < 2 minutes for full suite
+- **Flaky tests**: 0%
+
+## Phase 1: Testing Framework Setup (Week 1)
+
+### 1.1 Test Utilities Package
+**File**: `internal/testutil/helpers.go`
+
+```go
+package testutil
+
+import (
+ "testing"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// Must panics if error is not nil (use in test setup)
+func Must(t *testing.T, err error) {
+ t.Helper()
+ require.NoError(t, err)
+}
+
+// Fixture loads test fixture from file
+func Fixture(t *testing.T, name string) []byte {
+ t.Helper()
+ data, err := os.ReadFile(filepath.Join("testdata", name))
+ require.NoError(t, err)
+ return data
+}
+
+// TempDir creates a temp directory that auto-cleans
+func TempDir(t *testing.T) string {
+ t.Helper()
+ dir, err := os.MkdirTemp("", "cliproxy-test-*")
+ require.NoError(t, err)
+ t.Cleanup(func() { os.RemoveAll(dir) })
+ return dir
+}
+
+// Context returns a context with timeout for tests
+func Context(t *testing.T) context.Context {
+ t.Helper()
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ t.Cleanup(cancel)
+ return ctx
+}
+```
+
+### 1.2 Mock Generation
+**File**: `Makefile` additions
+
+```makefile
+.PHONY: mocks
+
+mocks:
+ go install github.com/vektra/mockery/v2@latest
+ mockery --config=.mockery.yml
+```
+
+**File**: `.mockery.yml`
+
+```yaml
+with-expecter: true
+keeptree: false
+dir: internal/domain/ports
+outpkg: mocks
+output: internal/testutil/mocks
+packages:
+ github.com/echyai/cliproxyapi/internal/domain/ports:
+ interfaces:
+ AuthRepository:
+ ConfigRepository:
+ TokenStore:
+ Translator:
+```
+
+### 1.3 Test Containers for Integration Tests
+**File**: `internal/testutil/containers.go`
+
+```go
+package testutil
+
+import (
+ "context"
+ "database/sql"
+ "testing"
+
+ "github.com/testcontainers/testcontainers-go"
+ "github.com/testcontainers/testcontainers-go/modules/postgres"
+ "github.com/testcontainers/testcontainers-go/modules/minio"
+)
+
+// PostgresContainer wraps test postgres container
+type PostgresContainer struct {
+ *postgres.PostgresContainer
+ ConnStr string
+}
+
+func NewPostgres(t *testing.T) *PostgresContainer {
+ t.Helper()
+
+ ctx := context.Background()
+
+ container, err := postgres.Run(ctx,
+ "postgres:16-alpine",
+ postgres.WithDatabase("test"),
+ postgres.WithUsername("test"),
+ postgres.WithPassword("test"),
+ )
+ require.NoError(t, err)
+
+ t.Cleanup(func() {
+ require.NoError(t, container.Terminate(ctx))
+ })
+
+ connStr, err := container.ConnectionString(ctx)
+ require.NoError(t, err)
+
+ return &PostgresContainer{
+ PostgresContainer: container,
+ ConnStr: connStr,
+ }
+}
+
+func (p *PostgresContainer) DB(t *testing.T) *sql.DB {
+ t.Helper()
+ db, err := sql.Open("postgres", p.ConnStr)
+ require.NoError(t, err)
+ t.Cleanup(func() { db.Close() })
+ return db
+}
+
+// MinIOContainer wraps test minio container
+type MinIOContainer struct {
+ *minio.MinioContainer
+ Endpoint string
+}
+
+func NewMinIO(t *testing.T) *MinIOContainer {
+ t.Helper()
+
+ ctx := context.Background()
+
+ container, err := minio.Run(ctx, "minio/minio:latest")
+ require.NoError(t, err)
+
+ t.Cleanup(func() {
+ require.NoError(t, container.Terminate(ctx))
+ })
+
+ endpoint, err := container.Endpoint(ctx, "http")
+ require.NoError(t, err)
+
+ return &MinIOContainer{
+ MinioContainer: container,
+ Endpoint: endpoint,
+ }
+}
+```
+
+## Phase 2: Unit Testing Patterns (Week 2-3)
+
+### 2.1 Table-Driven Tests
+**Example**: `internal/translator/gemini/translator_test.go`
+
+```go
+package gemini
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestTranslator_ConvertRequest(t *testing.T) {
+ tests := []struct {
+ name string
+ input openai.ChatCompletionRequest
+ want *gemini.GenerateContentRequest
+ wantErr bool
+ errMsg string
+ }{
+ {
+ name: "simple request with single message",
+ input: openai.ChatCompletionRequest{
+ Model: "gemini-pro",
+ Messages: []openai.ChatMessage{
+ {Role: "user", Content: "Hello"},
+ },
+ },
+ want: &gemini.GenerateContentRequest{
+ Model: "gemini-pro",
+ Contents: []gemini.Content{
+ {Role: "user", Parts: []gemini.Part{{Text: "Hello"}}},
+ },
+ },
+ },
+ {
+ name: "system message conversion",
+ input: openai.ChatCompletionRequest{
+ Model: "gemini-pro",
+ Messages: []openai.ChatMessage{
+ {Role: "system", Content: "You are helpful"},
+ {Role: "user", Content: "Hi"},
+ },
+ },
+ want: &gemini.GenerateContentRequest{
+ Model: "gemini-pro",
+ SystemInstruction: &gemini.Content{
+ Parts: []gemini.Part{{Text: "You are helpful"}},
+ },
+ Contents: []gemini.Content{
+ {Role: "user", Parts: []gemini.Part{{Text: "Hi"}}},
+ },
+ },
+ },
+ {
+ name: "unsupported model",
+ input: openai.ChatCompletionRequest{
+ Model: "unknown-model",
+ },
+ wantErr: true,
+ errMsg: "unsupported model",
+ },
+ {
+ name: "max tokens exceeded",
+ input: openai.ChatCompletionRequest{
+ Model: "gemini-pro",
+ MaxTokens: 1000000,
+ Messages: []openai.ChatMessage{{Role: "user", Content: "Hi"}},
+ },
+ wantErr: true,
+ errMsg: "max tokens exceeds limit",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ tr := NewTranslator()
+
+ got, err := tr.ConvertRequest(tt.input)
+
+ if tt.wantErr {
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), tt.errMsg)
+ return
+ }
+
+ require.NoError(t, err)
+ assert.Equal(t, tt.want, got)
+ })
+ }
+}
+```
+
+### 2.2 Mock-Based Tests
+**Example**: `internal/application/usecase/auth_test.go`
+
+```go
+package usecase
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/mock"
+
+ "github.com/echyai/cliproxyapi/internal/domain/models"
+ "github.com/echyai/cliproxyapi/internal/testutil/mocks"
+)
+
+func TestAuthUseCase_GetAuth(t *testing.T) {
+ tests := []struct {
+ name string
+ id string
+ mock func(*mocks.AuthRepository)
+ want *models.Auth
+ wantErr error
+ }{
+ {
+ name: "existing auth",
+ id: "auth-123",
+ mock: func(r *mocks.AuthRepository) {
+ r.On("FindByID", mock.Anything, "auth-123").
+ Return(&models.Auth{
+ ID: "auth-123",
+ Provider: "gemini",
+ Status: models.AuthStatusActive,
+ }, nil)
+ },
+ want: &models.Auth{
+ ID: "auth-123",
+ Provider: "gemini",
+ Status: models.AuthStatusActive,
+ },
+ },
+ {
+ name: "not found",
+ id: "auth-missing",
+ mock: func(r *mocks.AuthRepository) {
+ r.On("FindByID", mock.Anything, "auth-missing").
+ Return(nil, nil)
+ },
+ wantErr: domainerrors.NewConfigNotFound("auth-missing"),
+ },
+ {
+ name: "repository error",
+ id: "auth-err",
+ mock: func(r *mocks.AuthRepository) {
+ r.On("FindByID", mock.Anything, "auth-err").
+ Return(nil, errors.New("db error"))
+ },
+ wantErr: domainerrors.NewInternalError(errors.New("db error")),
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ mockRepo := mocks.NewAuthRepository(t)
+ tt.mock(mockRepo)
+
+ uc := NewAuthUseCase(mockRepo)
+ got, err := uc.GetAuth(testutil.Context(t), tt.id)
+
+ if tt.wantErr != nil {
+ assert.ErrorIs(t, err, tt.wantErr)
+ return
+ }
+
+ assert.NoError(t, err)
+ assert.Equal(t, tt.want, got)
+ mockRepo.AssertExpectations(t)
+ })
+ }
+}
+```
+
+## Phase 3: Integration Tests (Week 3-4)
+
+### 3.1 API Integration Tests
+**File**: `test/integration/api_test.go`
+
+```go
+package integration
+
+import (
+ "bytes"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/gin-gonic/gin"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ "github.com/echyai/cliproxyapi/internal/api"
+ "github.com/echyai/cliproxyapi/internal/testutil"
+)
+
+func TestAPI_ChatCompletions(t *testing.T) {
+ if testing.Short() {
+ t.Skip("skipping integration test")
+ }
+
+ // Setup test server
+ gin.SetMode(gin.TestMode)
+
+ container := testutil.NewPostgres(t)
+ server, err := api.NewTestServer(container.ConnStr)
+ require.NoError(t, err)
+
+ // Test cases
+ tests := []struct {
+ name string
+ request map[string]interface{}
+ wantStatus int
+ wantFields []string
+ }{
+ {
+ name: "valid request",
+ request: map[string]interface{}{
+ "model": "gemini-pro",
+ "messages": []map[string]string{
+ {"role": "user", "content": "Hello"},
+ },
+ },
+ wantStatus: 200,
+ wantFields: []string{"id", "choices", "usage"},
+ },
+ {
+ name: "missing model",
+ request: map[string]interface{}{
+ "messages": []map[string]string{
+ {"role": "user", "content": "Hello"},
+ },
+ },
+ wantStatus: 400,
+ wantFields: []string{"error"},
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ body, _ := json.Marshal(tt.request)
+ req := httptest.NewRequest("POST", "/v1/chat/completions", bytes.NewReader(body))
+ req.Header.Set("Content-Type", "application/json")
+ req.Header.Set("Authorization", "Bearer test-key")
+
+ w := httptest.NewRecorder()
+ server.Handler.ServeHTTP(w, req)
+
+ assert.Equal(t, tt.wantStatus, w.Code)
+
+ var resp map[string]interface{}
+ require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
+
+ for _, field := range tt.wantFields {
+ assert.Contains(t, resp, field)
+ }
+ })
+ }
+}
+```
+
+### 3.2 Contract Tests
+**File**: `test/contract/translator_contract_test.go`
+
+```go
+package contract
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ "github.com/echyai/cliproxyapi/internal/translator/gemini"
+ "github.com/echyai/cliproxyapi/internal/translator/openai"
+)
+
+// TestOpenAIGeminiRoundTrip verifies that translating OpenAI -> Gemini -> OpenAI
+// preserves essential fields
+func TestOpenAIGeminiRoundTrip(t *testing.T) {
+ original := openai.ChatCompletionRequest{
+ Model: "gemini-pro",
+ Messages: []openai.ChatMessage{
+ {Role: "system", Content: "Be helpful"},
+ {Role: "user", Content: "Hello"},
+ {Role: "assistant", Content: "Hi there!"},
+ {Role: "user", Content: "How are you?"},
+ },
+ Temperature: 0.7,
+ MaxTokens: 100,
+ TopP: 0.9,
+ }
+
+ geminiTranslator := gemini.NewTranslator()
+
+ // Convert to Gemini
+ geminiReq, err := geminiTranslator.ConvertRequest(original)
+ require.NoError(t, err)
+
+ // Convert back to OpenAI
+ openaiReq, err := geminiTranslator.ConvertResponse(geminiReq)
+ require.NoError(t, err)
+
+ // Verify essential fields preserved
+ assert.Equal(t, original.Model, openaiReq.Model)
+ assert.Len(t, openaiReq.Messages, len(original.Messages))
+ assert.InDelta(t, original.Temperature, openaiReq.Temperature, 0.01)
+}
+```
+
+## Phase 4: CI/CD Integration (Week 4)
+
+### 4.1 GitHub Actions Workflow
+**File**: `.github/workflows/test.yml`
+
+```yaml
+name: Test
+
+on:
+ push:
+ branches: [main, develop]
+ pull_request:
+ branches: [main, develop]
+
+jobs:
+ unit-test:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: actions/setup-go@v5
+ with:
+ go-version: '1.24'
+
+ - name: Run unit tests
+ run: go test -v -race -coverprofile=coverage.out ./internal/... ./sdk/...
+
+ - name: Upload coverage
+ uses: codecov/codecov-action@v3
+ with:
+ files: ./coverage.out
+ fail_ci_if_error: true
+
+ integration-test:
+ runs-on: ubuntu-latest
+ services:
+ postgres:
+ image: postgres:16
+ env:
+ POSTGRES_PASSWORD: test
+ POSTGRES_DB: test
+ options: >-
+ --health-cmd pg_isready
+ --health-interval 10s
+ --health-timeout 5s
+ --health-retries 5
+ ports:
+ - 5432:5432
+ minio:
+ image: minio/minio
+ env:
+ MINIO_ROOT_USER: test
+ MINIO_ROOT_PASSWORD: testpassword
+ ports:
+ - 9000:9000
+ options: >-
+ server /data
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: actions/setup-go@v5
+ with:
+ go-version: '1.24'
+
+ - name: Run integration tests
+ run: go test -v ./test/integration/... -tags=integration
+ env:
+ TEST_POSTGRES_URL: postgres://postgres:test@localhost:5432/test?sslmode=disable
+ TEST_MINIO_ENDPOINT: localhost:9000
+
+ contract-test:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: actions/setup-go@v5
+ with:
+ go-version: '1.24'
+
+ - name: Run contract tests
+ run: go test -v ./test/contract/...
+```
+
+## Success Metrics
+
+- [ ] 70%+ code coverage
+- [ ] All critical paths have integration tests
+- [ ] Contract tests for all translators
+- [ ] CI pipeline runs tests in < 5 minutes
+- [ ] Zero flaky tests
+
+## Testing Best Practices
+
+1. **Use subtests** for table-driven tests with `t.Run()`
+2. **Cleanup resources** with `t.Cleanup()`
+3. **Parallel tests** where possible with `t.Parallel()`
+4. **Golden files** for complex JSON responses
+5. **Property-based testing** for parsers using `rapid`
diff --git a/rule.md b/rule.md
new file mode 100644
index 0000000000000000000000000000000000000000..9e9b669109fd143c645f0c8be354b2373d0a24d4
--- /dev/null
+++ b/rule.md
@@ -0,0 +1,141 @@
+# Development Rules
+
+## General Principles (Clean Code)
+
+### Constants Over Magic Numbers
+- Replace hard-coded values with named constants.
+- Use descriptive constant names that explain the value's purpose.
+- Keep constants at the top of the file or in a dedicated constants file.
+
+### Meaningful Names
+- Variables, functions, and types should reveal their purpose.
+- Names should explain *why* something exists and *how* it's used.
+- Avoid abbreviations unless they're universally understood.
+- **Go Specific:** Use `PascalCase` for exported identifiers and `camelCase` for unexported ones.
+
+### Smart Comments
+- Don't comment on *what* the code does - make the code self-documenting.
+- Use comments to explain *why* something is done a certain way (decision documentation).
+- Document APIs, complex algorithms, and non-obvious side effects.
+
+### Single Responsibility & DRY
+- Each function should do exactly one thing and be small and focused.
+- Extract repeated code into reusable functions.
+- Maintain single sources of truth.
+
+### Encapsulation
+- Hide implementation details.
+- Expose clear interfaces.
+- Move nested conditionals into well-named functions.
+
+## Go Development Rules
+
+### Error Handling
+- **Always check errors:** `if err != nil { ... }`.
+- Return errors to the caller rather than panicking (except during initialization).
+- Use custom error types when beneficial for the caller.
+- Wrap errors with context when propagating them (e.g., `fmt.Errorf("failed to process item: %w", err)`).
+
+### Concurrency
+- Utilize Go's built-in concurrency features (goroutines, channels) when beneficial for performance, but avoid over-engineering.
+- Always manage goroutine lifecycles (use `context` for cancellation).
+- Use `sync.Mutex` or `sync.RWMutex` to protect shared state.
+
+### Dependency Management
+- Use Go Modules.
+- Group imports: Standard library, Third-party, Internal project imports.
+
+## Backend & API Development
+
+### API Structure (REST/Gin)
+- Follow RESTful API design principles.
+- Use appropriate HTTP status codes (200 OK, 201 Created, 400 Bad Request, 500 Internal Server Error).
+- Format JSON responses consistently.
+- Implement input validation for all API endpoints.
+
+### Security & Best Practices
+- **Input Validation:** Validate all incoming data.
+- **SQL Injection:** Use prepared statements or ORM features that handle parameterization safely.
+- **Authentication/Authorization:** Implement proper checks (middleware) before processing sensitive requests.
+- **Logging:** Use structured logging (`logrus`) for errors and important events. Do not log sensitive data (passwords, tokens).
+- **Rate Limiting:** Implement rate limiting to protect API resources.
+
+### Database Interaction
+- Use connection pooling to improve performance.
+- Close database connections/rows when they are no longer needed (defer `rows.Close()`).
+- Handle database errors gracefully.
+- Consider using an ORM for complex queries and data modeling.
+
+## Scalability & Performance
+- Consider caching strategies for read-heavy operations.
+- Optimize database queries (indexing, avoiding N+1 problems).
+- Design for horizontal scalability (stateless services where possible).
+
+## Version Control (Git)
+- Write clear, imperative commit messages (e.g., "Add user login endpoint" not "Added user login endpoint").
+- Make small, focused commits.
+- Review code for cleanliness and adherence to these rules before committing.
+
+# Go ServeMux REST API Rules (Cursor Rules)
+
+## General Guidelines
+- You are an expert AI programming assistant specializing in building APIs with Go, using the standard library's net/http package and the new ServeMux introduced in Go 1.22.
+- Always use the latest stable version of Go (1.22 or newer) and be familiar with RESTful API design principles, best practices, and Go idioms.
+- Follow the user's requirements carefully & to the letter.
+- **Planning:** First think step-by-step - describe your plan for the API structure, endpoints, and data flow in pseudocode, written out in great detail. Confirm the plan, then write code!
+- Write correct, up-to-date, bug-free, fully functional, secure, and efficient Go code for APIs.
+- Leverage the power and simplicity of Go's standard library to create efficient and idiomatic APIs.
+
+## Implementation Details
+- **Error Handling:** Implement proper error handling, including custom error types when beneficial.
+- **Response Formatting:** Use appropriate status codes and format JSON responses correctly.
+- **Validation:** Implement input validation for API endpoints.
+- **Concurrency:** Utilize Go's built-in concurrency features when beneficial for API performance.
+- **Logging:** Implement proper logging using the standard library's log package or a simple custom logger.
+- **Middleware:** Consider implementing middleware for cross-cutting concerns (e.g., logging, authentication).
+- **Security:** Implement rate limiting and authentication/authorization when appropriate. Always prioritize security, scalability, and maintainability.
+- **Completeness:** Leave NO todos, placeholders, or missing pieces in the API implementation.
+- **Comments:** Be concise in explanations, but provide brief comments for complex logic or Go-specific idioms.
+- **Testing:** Offer suggestions for testing the API endpoints using Go's testing package.
+
+# Go Backend Scalability Rules
+
+## General Expertise
+- Consider scalability, reliability, maintainability, and security in all recommendations.
+- Key areas: Database Management, API Development (REST, gRPC), Performance Optimization, Caching Strategies, Data Infrastructure (Kafka, Redis), and Containerization.
+
+## gRPC & Protocol Buffers
+- **Proto Files:** Define clear messages/services. Use proper types/naming. Ensure `go_package` is correct.
+- **Implementation:** Generate code with `protoc`. Handle errors/validation properly.
+- **Database:** Connect using `database/sql` or ORM (e.g. GORM). Use prepared statements.
+
+# Node.js and Express.js Best Practices
+
+## Project Structure
+- Use proper directory structure.
+- Implement proper module organization.
+- Keep routes organized by domain.
+- Implement proper error handling.
+
+## Express Setup
+- Use proper middleware setup.
+- Implement proper routing.
+- Configure proper security middleware (CORS, Helmet).
+- Implement proper validation.
+
+## Database & Auth
+- Use proper ORM/ODM (Mongoose/Sequelize/Prisma).
+- Implement proper migrations.
+- Implement proper JWT handling and password hashing.
+- Handle auth errors properly.
+
+## Performance & Security
+- Implement proper caching and async operations.
+- Implement proper rate limiting and input validation.
+- Use proper security headers.
+- Handle high traffic properly.
+
+## Testing & Deployment
+- Write proper unit and integration tests.
+- Use proper Docker setup and environment variables.
+- Implement proper CI/CD.
diff --git a/sdk/api/handlers/gemini/gemini-cli_handlers.go b/sdk/api/handlers/gemini/gemini-cli_handlers.go
index ea78657d6218a384e3b428d7205f526a64ae1540..5c0f1f308ac9a5b3e1553787910ce5a4b534853f 100644
--- a/sdk/api/handlers/gemini/gemini-cli_handlers.go
+++ b/sdk/api/handlers/gemini/gemini-cli_handlers.go
@@ -197,11 +197,11 @@ func (h *GeminiCLIAPIHandler) forwardCLIStream(c *gin.Context, flusher http.Flus
}
if !bytes.HasPrefix(chunk, []byte("data:")) {
- _, _ = c.Writer.Write([]byte("data: "))
+ _, _ = c.Writer.WriteString("data: ")
}
_, _ = c.Writer.Write(chunk)
- _, _ = c.Writer.Write([]byte("\n\n"))
+ _, _ = c.Writer.WriteString("\n\n")
} else {
_, _ = c.Writer.Write(chunk)
}
diff --git a/sdk/api/handlers/gemini/gemini_handlers.go b/sdk/api/handlers/gemini/gemini_handlers.go
index 71c485ad01257a20c2ef9d620a6ad99c76242188..dad751b494c17fdeb55ce15c74b7431aac54760a 100644
--- a/sdk/api/handlers/gemini/gemini_handlers.go
+++ b/sdk/api/handlers/gemini/gemini_handlers.go
@@ -235,9 +235,9 @@ func (h *GeminiAPIHandler) handleStreamGenerateContent(c *gin.Context, modelName
// Write first chunk
if alt == "" {
- _, _ = c.Writer.Write([]byte("data: "))
+ _, _ = c.Writer.WriteString("data: ")
_, _ = c.Writer.Write(chunk)
- _, _ = c.Writer.Write([]byte("\n\n"))
+ _, _ = c.Writer.WriteString("\n\n")
} else {
_, _ = c.Writer.Write(chunk)
}
@@ -308,9 +308,9 @@ func (h *GeminiAPIHandler) forwardGeminiStream(c *gin.Context, flusher http.Flus
KeepAliveInterval: keepAliveInterval,
WriteChunk: func(chunk []byte) {
if alt == "" {
- _, _ = c.Writer.Write([]byte("data: "))
+ _, _ = c.Writer.WriteString("data: ")
_, _ = c.Writer.Write(chunk)
- _, _ = c.Writer.Write([]byte("\n\n"))
+ _, _ = c.Writer.WriteString("\n\n")
} else {
_, _ = c.Writer.Write(chunk)
}
diff --git a/sdk/api/handlers/handlers.go b/sdk/api/handlers/handlers.go
index b1da966422dc7c2274265f0ee936dc49bf5e6cf5..a4b64376ccb0a4590d2607490ec8e12b584758d4 100644
--- a/sdk/api/handlers/handlers.go
+++ b/sdk/api/handlers/handlers.go
@@ -341,7 +341,7 @@ func (h *BaseAPIHandler) StartNonStreamingKeepAlive(c *gin.Context, ctx context.
case <-ctx.Done():
return
case <-ticker.C:
- _, _ = c.Writer.Write([]byte("\n"))
+ _, _ = c.Writer.WriteString("\n")
flusher.Flush()
}
}
diff --git a/sdk/api/handlers/openai/openai_handlers.go b/sdk/api/handlers/openai/openai_handlers.go
index 09471ce1d695eb32f56af2d2cf168bc7606d5237..7880c883faa6646fa21b31472913cccade311273 100644
--- a/sdk/api/handlers/openai/openai_handlers.go
+++ b/sdk/api/handlers/openai/openai_handlers.go
@@ -125,7 +125,6 @@ func (h *OpenAIAPIHandler) ChatCompletions(c *gin.Context) {
} else {
h.handleNonStreamingResponse(c, rawJSON)
}
-
}
// shouldTreatAsResponsesFormat detects OpenAI Responses-style payloads that are
@@ -170,7 +169,6 @@ func (h *OpenAIAPIHandler) Completions(c *gin.Context) {
} else {
h.handleCompletionsNonStreamingResponse(c, rawJSON)
}
-
}
// convertCompletionsRequestToChatCompletions converts OpenAI completions API request to chat completions format.
diff --git a/sdk/api/handlers/openai/openai_responses_handlers.go b/sdk/api/handlers/openai/openai_responses_handlers.go
index 31099f818a2bee5bdb71059b0bfe6353c32a8940..dcb1ec88bfdfc8620b694d11ff1781e7a8d2319d 100644
--- a/sdk/api/handlers/openai/openai_responses_handlers.go
+++ b/sdk/api/handlers/openai/openai_responses_handlers.go
@@ -88,7 +88,6 @@ func (h *OpenAIResponsesAPIHandler) Responses(c *gin.Context) {
} else {
h.handleNonStreamingResponse(c, rawJSON)
}
-
}
// handleNonStreamingResponse handles non-streaming chat completion responses
@@ -172,7 +171,7 @@ func (h *OpenAIResponsesAPIHandler) handleStreamingResponse(c *gin.Context, rawJ
if !ok {
// Stream closed without data? Send headers and done.
setSSEHeaders()
- _, _ = c.Writer.Write([]byte("\n"))
+ _, _ = c.Writer.WriteString("\n")
flusher.Flush()
cliCancel(nil)
return
@@ -183,10 +182,10 @@ func (h *OpenAIResponsesAPIHandler) handleStreamingResponse(c *gin.Context, rawJ
// Write first chunk logic (matching forwardResponsesStream)
if bytes.HasPrefix(chunk, []byte("event:")) {
- _, _ = c.Writer.Write([]byte("\n"))
+ _, _ = c.Writer.WriteString("\n")
}
_, _ = c.Writer.Write(chunk)
- _, _ = c.Writer.Write([]byte("\n"))
+ _, _ = c.Writer.WriteString("\n")
flusher.Flush()
// Continue
@@ -200,10 +199,10 @@ func (h *OpenAIResponsesAPIHandler) forwardResponsesStream(c *gin.Context, flush
h.ForwardStream(c, flusher, cancel, data, errs, handlers.StreamForwardOptions{
WriteChunk: func(chunk []byte) {
if bytes.HasPrefix(chunk, []byte("event:")) {
- _, _ = c.Writer.Write([]byte("\n"))
+ _, _ = c.Writer.WriteString("\n")
}
_, _ = c.Writer.Write(chunk)
- _, _ = c.Writer.Write([]byte("\n"))
+ _, _ = c.Writer.WriteString("\n")
},
WriteTerminalError: func(errMsg *interfaces.ErrorMessage) {
if errMsg == nil {
@@ -221,7 +220,7 @@ func (h *OpenAIResponsesAPIHandler) forwardResponsesStream(c *gin.Context, flush
_, _ = fmt.Fprintf(c.Writer, "\nevent: error\ndata: %s\n\n", string(body))
},
WriteDone: func() {
- _, _ = c.Writer.Write([]byte("\n"))
+ _, _ = c.Writer.WriteString("\n")
},
})
}
diff --git a/sdk/api/handlers/stream_forwarder.go b/sdk/api/handlers/stream_forwarder.go
index 401baca8fae38cde32d841e5b70f729ae3cca9dd..8f9e251e54d152fcf2dc8677d3926c4380f894a0 100644
--- a/sdk/api/handlers/stream_forwarder.go
+++ b/sdk/api/handlers/stream_forwarder.go
@@ -45,7 +45,7 @@ func (h *BaseAPIHandler) ForwardStream(c *gin.Context, flusher http.Flusher, can
writeKeepAlive := opts.WriteKeepAlive
if writeKeepAlive == nil {
writeKeepAlive = func() {
- _, _ = c.Writer.Write([]byte(": keep-alive\n\n"))
+ _, _ = c.Writer.WriteString(": keep-alive\n\n")
}
}
diff --git a/sdk/auth/claude.go b/sdk/auth/claude.go
index 2c7a89888a09ccc4b5830086c1b64b40a360fc27..71df797adc363a4176c30b62695cd6474116f124 100644
--- a/sdk/auth/claude.go
+++ b/sdk/auth/claude.go
@@ -9,6 +9,7 @@ import (
"github.com/router-for-me/CLIProxyAPI/v6/internal/auth/claude"
"github.com/router-for-me/CLIProxyAPI/v6/internal/browser"
+
// legacy client removed
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
"github.com/router-for-me/CLIProxyAPI/v6/internal/misc"
diff --git a/sdk/auth/codex.go b/sdk/auth/codex.go
index b655a23945e2a00b495400ef56dc2e5fd4753df1..0fe164317a0f09dcf9605e7f6d1db369f6f78cf6 100644
--- a/sdk/auth/codex.go
+++ b/sdk/auth/codex.go
@@ -11,6 +11,7 @@ import (
"github.com/router-for-me/CLIProxyAPI/v6/internal/auth/codex"
"github.com/router-for-me/CLIProxyAPI/v6/internal/browser"
+
// legacy client removed
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
"github.com/router-for-me/CLIProxyAPI/v6/internal/misc"
diff --git a/sdk/auth/qwen.go b/sdk/auth/qwen.go
index 151fba6816e279ae04d4f8645c0a837dcce53414..3d180ea2a2722c62cce32275ec832e8b0c397d81 100644
--- a/sdk/auth/qwen.go
+++ b/sdk/auth/qwen.go
@@ -8,6 +8,7 @@ import (
"github.com/router-for-me/CLIProxyAPI/v6/internal/auth/qwen"
"github.com/router-for-me/CLIProxyAPI/v6/internal/browser"
+
// legacy client removed
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
diff --git a/sdk/cliproxy/auth/conductor.go b/sdk/cliproxy/auth/conductor.go
index 3a64c8c3476c29db7f6f756380862f0ac8025f56..0999ff01303484c190ad4211e990b0b58e66cadd 100644
--- a/sdk/cliproxy/auth/conductor.go
+++ b/sdk/cliproxy/auth/conductor.go
@@ -244,7 +244,6 @@ func (m *Manager) lookupAPIKeyUpstreamModel(authID, requestedModel string) strin
return resolved + "(" + requestResult.RawSuffix + ")"
}
return resolved
-
}
func (m *Manager) rebuildAPIKeyModelAliasFromRuntimeConfig() {
diff --git a/sdk/cliproxy/auth/types.go b/sdk/cliproxy/auth/types.go
index b2bbe0a2eafccaa4bfd14de62c054f4cc3e49e07..a1f61010389121c87166fae22813bf3c115f4269 100644
--- a/sdk/cliproxy/auth/types.go
+++ b/sdk/cliproxy/auth/types.go
@@ -301,7 +301,7 @@ func (a *Auth) AccountInfo() (string, string) {
return "", ""
}
// For Gemini CLI, include project ID in the OAuth account info if present.
- if strings.ToLower(a.Provider) == "gemini-cli" {
+ if strings.EqualFold(a.Provider, "gemini-cli") {
if a.Metadata != nil {
email, _ := a.Metadata["email"].(string)
email = strings.TrimSpace(email)
@@ -318,7 +318,7 @@ func (a *Auth) AccountInfo() (string, string) {
}
// For iFlow provider, prioritize OAuth type if email is present
- if strings.ToLower(a.Provider) == "iflow" {
+ if strings.EqualFold(a.Provider, "iflow") {
if a.Metadata != nil {
if email, ok := a.Metadata["email"].(string); ok {
email = strings.TrimSpace(email)
diff --git a/test/amp_management_test.go b/test/amp_management_test.go
index e384ef0e8bf909bdb10b33ae3c8b417e1fce8eb3..0e7412a12259ab9ba192a39711bf1e9b21b457a0 100644
--- a/test/amp_management_test.go
+++ b/test/amp_management_test.go
@@ -77,7 +77,7 @@ func TestGetAmpCode(t *testing.T) {
h, _ := newAmpTestHandler(t)
r := setupAmpRouter(h)
- req := httptest.NewRequest(http.MethodGet, "/v0/management/ampcode", nil)
+ req := httptest.NewRequest(http.MethodGet, "/v0/management/ampcode", http.NoBody)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
@@ -104,7 +104,7 @@ func TestGetAmpUpstreamURL(t *testing.T) {
h, _ := newAmpTestHandler(t)
r := setupAmpRouter(h)
- req := httptest.NewRequest(http.MethodGet, "/v0/management/ampcode/upstream-url", nil)
+ req := httptest.NewRequest(http.MethodGet, "/v0/management/ampcode/upstream-url", http.NoBody)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
@@ -143,7 +143,7 @@ func TestDeleteAmpUpstreamURL(t *testing.T) {
h, _ := newAmpTestHandler(t)
r := setupAmpRouter(h)
- req := httptest.NewRequest(http.MethodDelete, "/v0/management/ampcode/upstream-url", nil)
+ req := httptest.NewRequest(http.MethodDelete, "/v0/management/ampcode/upstream-url", http.NoBody)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
@@ -157,7 +157,7 @@ func TestGetAmpUpstreamAPIKey(t *testing.T) {
h, _ := newAmpTestHandler(t)
r := setupAmpRouter(h)
- req := httptest.NewRequest(http.MethodGet, "/v0/management/ampcode/upstream-api-key", nil)
+ req := httptest.NewRequest(http.MethodGet, "/v0/management/ampcode/upstream-api-key", http.NoBody)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
@@ -223,7 +223,7 @@ func TestPutAmpUpstreamAPIKeys_PersistsAndReturns(t *testing.T) {
}
// Verify it is returned by GET /ampcode
- req = httptest.NewRequest(http.MethodGet, "/v0/management/ampcode", nil)
+ req = httptest.NewRequest(http.MethodGet, "/v0/management/ampcode", http.NoBody)
w = httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
@@ -261,7 +261,7 @@ func TestDeleteAmpUpstreamAPIKeys_ClearsAll(t *testing.T) {
t.Fatalf("expected status %d, got %d", http.StatusOK, w.Code)
}
- req = httptest.NewRequest(http.MethodGet, "/v0/management/ampcode/upstream-api-keys", nil)
+ req = httptest.NewRequest(http.MethodGet, "/v0/management/ampcode/upstream-api-keys", http.NoBody)
w = httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
@@ -281,7 +281,7 @@ func TestDeleteAmpUpstreamAPIKey(t *testing.T) {
h, _ := newAmpTestHandler(t)
r := setupAmpRouter(h)
- req := httptest.NewRequest(http.MethodDelete, "/v0/management/ampcode/upstream-api-key", nil)
+ req := httptest.NewRequest(http.MethodDelete, "/v0/management/ampcode/upstream-api-key", http.NoBody)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
@@ -295,7 +295,7 @@ func TestGetAmpRestrictManagementToLocalhost(t *testing.T) {
h, _ := newAmpTestHandler(t)
r := setupAmpRouter(h)
- req := httptest.NewRequest(http.MethodGet, "/v0/management/ampcode/restrict-management-to-localhost", nil)
+ req := httptest.NewRequest(http.MethodGet, "/v0/management/ampcode/restrict-management-to-localhost", http.NoBody)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
@@ -334,7 +334,7 @@ func TestGetAmpModelMappings(t *testing.T) {
h, _ := newAmpTestHandler(t)
r := setupAmpRouter(h)
- req := httptest.NewRequest(http.MethodGet, "/v0/management/ampcode/model-mappings", nil)
+ req := httptest.NewRequest(http.MethodGet, "/v0/management/ampcode/model-mappings", http.NoBody)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
@@ -409,7 +409,7 @@ func TestDeleteAmpModelMappings_All(t *testing.T) {
h, _ := newAmpTestHandler(t)
r := setupAmpRouter(h)
- req := httptest.NewRequest(http.MethodDelete, "/v0/management/ampcode/model-mappings", nil)
+ req := httptest.NewRequest(http.MethodDelete, "/v0/management/ampcode/model-mappings", http.NoBody)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
@@ -423,7 +423,7 @@ func TestGetAmpForceModelMappings(t *testing.T) {
h, _ := newAmpTestHandler(t)
r := setupAmpRouter(h)
- req := httptest.NewRequest(http.MethodGet, "/v0/management/ampcode/force-model-mappings", nil)
+ req := httptest.NewRequest(http.MethodGet, "/v0/management/ampcode/force-model-mappings", http.NoBody)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
@@ -472,7 +472,7 @@ func TestPutAmpModelMappings_VerifyState(t *testing.T) {
t.Fatalf("PUT failed: status %d, body: %s", w.Code, w.Body.String())
}
- req = httptest.NewRequest(http.MethodGet, "/v0/management/ampcode/model-mappings", nil)
+ req = httptest.NewRequest(http.MethodGet, "/v0/management/ampcode/model-mappings", http.NoBody)
w = httptest.NewRecorder()
r.ServeHTTP(w, req)
@@ -509,7 +509,7 @@ func TestPatchAmpModelMappings_VerifyState(t *testing.T) {
t.Fatalf("PATCH failed: status %d", w.Code)
}
- req = httptest.NewRequest(http.MethodGet, "/v0/management/ampcode/model-mappings", nil)
+ req = httptest.NewRequest(http.MethodGet, "/v0/management/ampcode/model-mappings", http.NoBody)
w = httptest.NewRecorder()
r.ServeHTTP(w, req)
@@ -557,7 +557,7 @@ func TestDeleteAmpModelMappings_VerifyState(t *testing.T) {
t.Fatalf("DELETE failed: status %d", w.Code)
}
- req = httptest.NewRequest(http.MethodGet, "/v0/management/ampcode/model-mappings", nil)
+ req = httptest.NewRequest(http.MethodGet, "/v0/management/ampcode/model-mappings", http.NoBody)
w = httptest.NewRecorder()
r.ServeHTTP(w, req)
@@ -590,7 +590,7 @@ func TestDeleteAmpModelMappings_NonExistent(t *testing.T) {
t.Fatalf("expected status %d, got %d", http.StatusOK, w.Code)
}
- req = httptest.NewRequest(http.MethodGet, "/v0/management/ampcode/model-mappings", nil)
+ req = httptest.NewRequest(http.MethodGet, "/v0/management/ampcode/model-mappings", http.NoBody)
w = httptest.NewRecorder()
r.ServeHTTP(w, req)
@@ -619,7 +619,7 @@ func TestPutAmpModelMappings_Empty(t *testing.T) {
t.Fatalf("expected status %d, got %d", http.StatusOK, w.Code)
}
- req = httptest.NewRequest(http.MethodGet, "/v0/management/ampcode/model-mappings", nil)
+ req = httptest.NewRequest(http.MethodGet, "/v0/management/ampcode/model-mappings", http.NoBody)
w = httptest.NewRecorder()
r.ServeHTTP(w, req)
@@ -648,7 +648,7 @@ func TestPutAmpUpstreamURL_VerifyState(t *testing.T) {
t.Fatalf("PUT failed: status %d", w.Code)
}
- req = httptest.NewRequest(http.MethodGet, "/v0/management/ampcode/upstream-url", nil)
+ req = httptest.NewRequest(http.MethodGet, "/v0/management/ampcode/upstream-url", http.NoBody)
w = httptest.NewRecorder()
r.ServeHTTP(w, req)
@@ -667,7 +667,7 @@ func TestDeleteAmpUpstreamURL_VerifyState(t *testing.T) {
h, _ := newAmpTestHandler(t)
r := setupAmpRouter(h)
- req := httptest.NewRequest(http.MethodDelete, "/v0/management/ampcode/upstream-url", nil)
+ req := httptest.NewRequest(http.MethodDelete, "/v0/management/ampcode/upstream-url", http.NoBody)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
@@ -675,7 +675,7 @@ func TestDeleteAmpUpstreamURL_VerifyState(t *testing.T) {
t.Fatalf("DELETE failed: status %d", w.Code)
}
- req = httptest.NewRequest(http.MethodGet, "/v0/management/ampcode/upstream-url", nil)
+ req = httptest.NewRequest(http.MethodGet, "/v0/management/ampcode/upstream-url", http.NoBody)
w = httptest.NewRecorder()
r.ServeHTTP(w, req)
@@ -704,7 +704,7 @@ func TestPutAmpUpstreamAPIKey_VerifyState(t *testing.T) {
t.Fatalf("PUT failed: status %d", w.Code)
}
- req = httptest.NewRequest(http.MethodGet, "/v0/management/ampcode/upstream-api-key", nil)
+ req = httptest.NewRequest(http.MethodGet, "/v0/management/ampcode/upstream-api-key", http.NoBody)
w = httptest.NewRecorder()
r.ServeHTTP(w, req)
@@ -723,7 +723,7 @@ func TestDeleteAmpUpstreamAPIKey_VerifyState(t *testing.T) {
h, _ := newAmpTestHandler(t)
r := setupAmpRouter(h)
- req := httptest.NewRequest(http.MethodDelete, "/v0/management/ampcode/upstream-api-key", nil)
+ req := httptest.NewRequest(http.MethodDelete, "/v0/management/ampcode/upstream-api-key", http.NoBody)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
@@ -731,7 +731,7 @@ func TestDeleteAmpUpstreamAPIKey_VerifyState(t *testing.T) {
t.Fatalf("DELETE failed: status %d", w.Code)
}
- req = httptest.NewRequest(http.MethodGet, "/v0/management/ampcode/upstream-api-key", nil)
+ req = httptest.NewRequest(http.MethodGet, "/v0/management/ampcode/upstream-api-key", http.NoBody)
w = httptest.NewRecorder()
r.ServeHTTP(w, req)
@@ -760,7 +760,7 @@ func TestPutAmpRestrictManagementToLocalhost_VerifyState(t *testing.T) {
t.Fatalf("PUT failed: status %d", w.Code)
}
- req = httptest.NewRequest(http.MethodGet, "/v0/management/ampcode/restrict-management-to-localhost", nil)
+ req = httptest.NewRequest(http.MethodGet, "/v0/management/ampcode/restrict-management-to-localhost", http.NoBody)
w = httptest.NewRecorder()
r.ServeHTTP(w, req)
@@ -789,7 +789,7 @@ func TestPutAmpForceModelMappings_VerifyState(t *testing.T) {
t.Fatalf("PUT failed: status %d", w.Code)
}
- req = httptest.NewRequest(http.MethodGet, "/v0/management/ampcode/force-model-mappings", nil)
+ req = httptest.NewRequest(http.MethodGet, "/v0/management/ampcode/force-model-mappings", http.NoBody)
w = httptest.NewRecorder()
r.ServeHTTP(w, req)
@@ -842,7 +842,7 @@ func TestComplexMappingsWorkflow(t *testing.T) {
w = httptest.NewRecorder()
r.ServeHTTP(w, req)
- req = httptest.NewRequest(http.MethodGet, "/v0/management/ampcode/model-mappings", nil)
+ req = httptest.NewRequest(http.MethodGet, "/v0/management/ampcode/model-mappings", http.NoBody)
w = httptest.NewRecorder()
r.ServeHTTP(w, req)
@@ -875,7 +875,7 @@ func TestNilHandlerGetAmpCode(t *testing.T) {
h := management.NewHandler(cfg, "", nil)
r := setupAmpRouter(h)
- req := httptest.NewRequest(http.MethodGet, "/v0/management/ampcode", nil)
+ req := httptest.NewRequest(http.MethodGet, "/v0/management/ampcode", http.NoBody)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
@@ -896,7 +896,7 @@ func TestEmptyConfigGetAmpModelMappings(t *testing.T) {
h := management.NewHandler(cfg, configPath, nil)
r := setupAmpRouter(h)
- req := httptest.NewRequest(http.MethodGet, "/v0/management/ampcode/model-mappings", nil)
+ req := httptest.NewRequest(http.MethodGet, "/v0/management/ampcode/model-mappings", http.NoBody)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)