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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ on:
branches: [main]

env:
GO_VERSION: '1.26.10'
GO_VERSION: '1.26.11'

jobs:
build:
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/go-vulnerability-gate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ jobs:
run: echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
- uses: actions/setup-go@v6
with:
go-version: '1.26.10'
go-version: '1.26.11'
cache: true
- run: go install golang.org/x/vuln/cmd/govulncheck@v1.9.0
- run: govulncheck ./...
2 changes: 1 addition & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ jobs:

- uses: actions/setup-go@v6
with:
go-version: '1.26.10'
go-version: '1.26.11'
cache: true

- name: Verify scanned checkout
Expand Down
11 changes: 11 additions & 0 deletions .jules/bolt.md
Original file line number Diff line number Diff line change
Expand Up @@ -219,3 +219,14 @@ go tool pprof mem.prof
Remember: You're Bolt, making switchAILocal lightning fast. But speed without correctness is useless. Measure, optimize, verify.

**If you can't find a clear performance win today, stop and do not create a PR.**
## $(date +%Y-%m-%d) - Add sync.Pool for ResponseWriterWrapper buffer
**Learning:** In the hot path of HTTP request handling (`internal/api/middleware/response_writer.go`), a new `bytes.Buffer` was being allocated for every non-streaming response. This results in significant memory allocation overhead for high-throughput API gateways.
**Action:** Implemented a `sync.Pool` for `bytes.Buffer` and added a `Release()` method to recycle buffers after the request completes. Included a capacity check (`Cap() <= 128*1024`) before returning the buffer to the pool to prevent memory bloat from occasionally large responses.

## $(date +%Y-%m-%d) - Bump Go Version for Vulnerability Fix
**Learning:** Found a vulnerability (GO-2026-6859) in the Go standard library (`net/http`) while running `govulncheck`. The vulnerability is an HTTP/1 server crash due to a MaxBytesReader header race.
**Action:** Bumped the Go version in `go.mod` and `.github/workflows/go-vulnerability-gate.yml` from `1.26.10` to `1.26.11` to resolve the vulnerability. This ensures the CI check for govulncheck passes.

## $(date +%Y-%m-%d) - Bump Go Version in CI Files
**Learning:** Bumping the Go version in `go.mod` to resolve vulnerabilities requires also updating the Go version specified in all CI workflow files (like `ci.yml` and `release.yml`) to ensure the CI uses the correct toolchain and passes build checks.
**Action:** Updated `GO_VERSION` in `.github/workflows/ci.yml` and `go-version` in `.github/workflows/release.yml` from `1.26.10` to `1.26.11` to fix CI failures.
6 changes: 3 additions & 3 deletions cmd/server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -1047,18 +1047,18 @@ func doMemoryReset(cfg *config.Config, opts *MemoryOptions) {
// Create automatic backup before reset
timestamp := time.Now().Format("20060102-150405")
backupPath := fmt.Sprintf("memory-backup-before-reset-%s.tar.gz", timestamp)

fmt.Println("Creating automatic backup before reset...")
fmt.Printf("Backup file: %s\n", backupPath)

// Create backup
if err := createMemoryBackup(cfg, backupPath); err != nil {
fmt.Fprintf(os.Stderr, "\n❌ Error: Failed to create backup: %v\n", err)
fmt.Fprintf(os.Stderr, "Reset cancelled for safety. Your data is preserved.\n")
fmt.Fprintf(os.Stderr, "\nπŸ’‘ Tip: Ensure you have write permissions and sufficient disk space.\n")
os.Exit(1)
}

fmt.Printf("βœ“ Backup created successfully: %s\n\n", backupPath)
fmt.Println("Proceeding with reset...")

Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
module github.com/traylinx/switchAILocal

go 1.26.10
go 1.26.11

require (
github.com/DATA-DOG/go-sqlmock v1.5.2
Expand Down
8 changes: 4 additions & 4 deletions internal/access/config_access/provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ func (p *provider) Authenticate(_ context.Context, r *http.Request) (*sdkaccess.
if p == nil {
return nil, sdkaccess.ErrNotHandled
}

authHeader := r.Header.Get("Authorization")
authHeaderGoogle := r.Header.Get("X-Goog-Api-Key")
authHeaderAnthropic := r.Header.Get("X-Api-Key")
Expand Down Expand Up @@ -96,7 +96,7 @@ func (p *provider) Authenticate(_ context.Context, r *http.Request) (*sdkaccess.
return &sdkaccess.Result{
Provider: p.Identifier(),
Principal: candidate.value,
Metadata: map[string]string{"source": candidate.source},
Metadata: map[string]string{"source": candidate.source},
}, nil
}
}
Expand All @@ -108,13 +108,13 @@ func (p *provider) Authenticate(_ context.Context, r *http.Request) (*sdkaccess.
return &sdkaccess.Result{
Provider: p.Identifier(),
Principal: firstCandidate,
Metadata: map[string]string{"source": firstSource},
Metadata: map[string]string{"source": firstSource},
}, nil
}
return &sdkaccess.Result{
Provider: p.Identifier(),
Principal: "anonymous",
Metadata: map[string]string{"source": "none"},
Metadata: map[string]string{"source": "none"},
}, nil
}

Expand Down
4 changes: 2 additions & 2 deletions internal/access/config_access/provider_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@ package configaccess
import (
"context"
"net/http"
"testing"
"net/url"
"testing"

sdkaccess "github.com/traylinx/switchAILocal/sdk/access"
sdkconfig "github.com/traylinx/switchAILocal/sdk/config"
Expand Down Expand Up @@ -109,7 +109,7 @@ func TestProvider_Authenticate(t *testing.T) {
for k, v := range tt.headers {
req.Header.Set(k, v)
}

// Add queryparams
q := req.URL.Query()
for k, v := range tt.queryParams {
Expand Down
1 change: 1 addition & 0 deletions internal/api/middleware/request_logging.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ func RequestLoggingMiddleware(logger logging.RequestLogger) gin.HandlerFunc {
// Error is logged but we continue serving the response
_ = err
}
wrapper.Release()
}
}

Expand Down
21 changes: 20 additions & 1 deletion internal/api/middleware/response_writer.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,20 @@ import (
"bytes"
"net/http"
"strings"
"sync"

"github.com/gin-gonic/gin"
"github.com/traylinx/switchAILocal/internal/interfaces"
"github.com/traylinx/switchAILocal/internal/logging"
)

// bufferPool is a sync.Pool for bytes.Buffer to reduce allocations during response logging.
var bufferPool = sync.Pool{
New: func() interface{} {
return &bytes.Buffer{}
},
}

// RequestInfo holds essential details of an incoming HTTP request for logging purposes.
type RequestInfo struct {
URL string // URL is the request URL.
Expand Down Expand Up @@ -55,7 +63,7 @@ type ResponseWriterWrapper struct {
func NewResponseWriterWrapper(w gin.ResponseWriter, logger logging.RequestLogger, requestInfo *RequestInfo) *ResponseWriterWrapper {
return &ResponseWriterWrapper{
ResponseWriter: w,
body: &bytes.Buffer{},
body: bufferPool.Get().(*bytes.Buffer),
logger: logger,
requestInfo: requestInfo,
headers: make(map[string][]string),
Expand Down Expand Up @@ -241,6 +249,17 @@ func (w *ResponseWriterWrapper) processStreamingChunks(done chan struct{}) {
}
}

// Release returns the buffer to the pool. It should be called after Finalize.
func (w *ResponseWriterWrapper) Release() {
if w.body != nil {
if w.body.Cap() <= 128*1024 { // 128KB limit to avoid memory bloat
w.body.Reset()
bufferPool.Put(w.body)
}
w.body = nil
}
}

// Finalize completes the logging process for the request and response.
// For streaming responses, it closes the chunk channel and the stream writer.
// For non-streaming responses, it logs the complete request and response details,
Expand Down
52 changes: 26 additions & 26 deletions internal/api/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -397,10 +397,10 @@ func NewServer(cfg *config.Config, authManager *auth.Manager, accessManager *sdk
var autoResolver *autoroute.AutoResolver
if cfg.AutoRouting.Enabled {
log.Info("Initializing Intelligent Auto-Routing System")

// 1. Setup Discovery Service
discoverySvc := autoroute.NewDiscoveryService(cfg.AutoRouting.Discovery)

// 2. Register probers based on configured credentials
// CLI Probers
discoverySvc.RegisterProber(probes.NewCLIProber("geminicli", "gemini"))
Expand All @@ -412,16 +412,16 @@ func NewServer(cfg *config.Config, authManager *auth.Manager, accessManager *sdk
name := fmt.Sprintf("gemini-api-%d", i)
endpoint := "/v1beta/models"
if key.ModelsURL != "" {
endpoint = key.ModelsURL
endpoint = key.ModelsURL
}
discoverySvc.RegisterProber(probes.NewAPIProber(name, key.BaseURL, endpoint, key.APIKey, "Authorization"))
}

for i, key := range cfg.ClaudeKey {
name := fmt.Sprintf("claude-api-%d", i)
endpoint := "/v1/models"
if key.ModelsURL != "" {
endpoint = key.ModelsURL
endpoint = key.ModelsURL
}
discoverySvc.RegisterProber(probes.NewAPIProber(name, key.BaseURL, endpoint, key.APIKey, "x-api-key"))
}
Expand All @@ -437,47 +437,47 @@ func NewServer(cfg *config.Config, authManager *auth.Manager, accessManager *sdk
// 3. Run Boot-Time Discovery
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()

var candidates []autoroute.CandidateInput
if cfg.AutoRouting.Discovery.ProbeOnStartup {
log.Info("Running boot-time provider discovery")
results := discoverySvc.DiscoverAll(ctx)

// 4. Convert discovery results to CandidateInputs
for provider, res := range results {
if !res.Available {
log.WithField("provider", provider).Warn("Provider unavailable during discovery")
continue
}

for _, model := range res.Models {
candidates = append(candidates, autoroute.CandidateInput{
Provider: provider,
Model: model.ID,
Available: true,
Latency: res.Latency,
SuccessRate: 1.0,
QuotaHealth: 1.0,
})
candidates = append(candidates, autoroute.CandidateInput{
Provider: provider,
Model: model.ID,
Available: true,
Latency: res.Latency,
SuccessRate: 1.0,
QuotaHealth: 1.0,
})
}

log.WithFields(log.Fields{
"provider": provider,
"models": len(res.Models),
"latency": res.Latency.Milliseconds(),
}).Info("Discovered provider models")
}
} else {
// If probing disabled on boot, just populate basic fallback providers
// If probing disabled on boot, just populate basic fallback providers
for _, p := range []string{"geminicli", "claudecli", "codex", "ollama"} {
candidates = append(candidates, autoroute.CandidateInput{
Provider: p,
Model: "auto",
Available: true,
Latency: 0,
candidates = append(candidates, autoroute.CandidateInput{
Provider: p,
Model: "auto",
Available: true,
Latency: 0,
SuccessRate: 1.0,
QuotaHealth: 1.0,
})
})
}
}

Expand Down Expand Up @@ -595,7 +595,7 @@ func (s *Server) setupRoutes() {
if s.cfg != nil {
observability.RegisterMetricsRoute(s.engine, s.cfg.Observability.Metrics.Enabled, s.cfg.Observability.Metrics.Path)
}

s.engine.GET("/management", s.serveManagementControlPanel)
s.engine.GET("/management.html", s.serveManagementControlPanel)
openaiHandlers := openai.NewOpenAIAPIHandler(s.handlers)
Expand Down Expand Up @@ -1454,7 +1454,7 @@ func AuthMiddleware(manager *sdkaccess.Manager) gin.HandlerFunc {
if len(result.Metadata) > 0 {
c.Set("accessMetadata", result.Metadata)
}

hasher := sha256.New()
hasher.Write([]byte(result.Principal))
c.Set("api_key_hash", "sha256:"+hex.EncodeToString(hasher.Sum(nil)))
Expand Down
4 changes: 2 additions & 2 deletions internal/api/server_coordinator_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -216,7 +216,7 @@ func TestCreatePipelineIntegratorFromCoordinator_ValidCoordinator(t *testing.T)
integrator := createPipelineIntegratorFromCoordinator(mockCoordinator)

assert.NotNil(t, integrator)

// Verify the integrator can be used
model, messages, err := integrator.ApplySteering(nil, []map[string]string{})
assert.NoError(t, err)
Expand Down Expand Up @@ -244,7 +244,7 @@ func TestCreateEventBusIntegratorFromCoordinator_ValidCoordinator(t *testing.T)
integrator := createEventBusIntegratorFromCoordinator(mockCoordinator)

assert.NotNil(t, integrator)

// Verify the integrator can be used
err := integrator.ConnectHeartbeatEvents()
assert.NoError(t, err)
Expand Down
Loading
Loading