Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
c6e0dcb
fix(router): route only to models available from registered providers
mouse-value-add Apr 14, 2026
f2eb8f0
Merge pull request #17 from mouse-value-add/chore/frugal-afternoon-20…
brainsparker Apr 14, 2026
1ce94cd
fix(dx): normalize X-Frugal-Quality parsing
mouse-value-add Apr 15, 2026
3f384a6
Merge pull request #19 from mouse-value-add/chore/frugal-morning-2026…
brainsparker Apr 15, 2026
2148a28
fix(router): default unknown quality thresholds to balanced
mouse-value-add Apr 15, 2026
ad99547
Merge pull request #20 from mouse-value-add/chore/frugal-afternoon-20…
brainsparker Apr 15, 2026
4de3318
fix(config): reject unknown YAML fields during load
mouse-value-add Apr 16, 2026
0212dc0
Merge pull request #22 from mouse-value-add/chore/frugal-morning-2026…
brainsparker Apr 16, 2026
98b27b7
fix(proxy): cap fallback attempts to bound latency and spend
mouse-value-add Apr 17, 2026
32349e5
Merge pull request #24 from mouse-value-add/chore/frugal-nightly-hard…
brainsparker Apr 17, 2026
c7fe45e
fix(streaming): raise SSE scanner buffer to avoid large-chunk failures
mouse-value-add Apr 17, 2026
74e8384
Merge pull request #25 from mouse-value-add/chore/frugal-morning-2026…
brainsparker Apr 17, 2026
9b3bfa8
feat(server): add hardened HTTP timeouts with env overrides
mouse-value-add Apr 19, 2026
1251955
Merge pull request #30 from mouse-value-add/chore/frugal-nightly-hard…
brainsparker Apr 19, 2026
a76cd18
hardening: cap HTTP header bytes with validated env override
mouse-value-add Apr 19, 2026
18f6425
Merge pull request #32 from mouse-value-add/chore/frugal-afternoon-20…
brainsparker Apr 19, 2026
99dafa0
fix(wrap): fail fast when local proxy health check never succeeds
mouse-value-add Apr 20, 2026
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
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,14 @@ Then set the env var yourself:
export OPENAI_BASE_URL=http://localhost:8080/v1
```

Optional hardening timeouts (Go duration syntax):

- `FRUGAL_READ_HEADER_TIMEOUT` (default `5s`)
- `FRUGAL_READ_TIMEOUT` (default `15s`)
- `FRUGAL_WRITE_TIMEOUT` (default `120s`)
- `FRUGAL_IDLE_TIMEOUT` (default `60s`)
- `FRUGAL_MAX_HEADER_BYTES` (default `1048576`)

### Quality thresholds

Control cost vs. quality per request:
Expand Down Expand Up @@ -121,6 +129,7 @@ headers = {"X-Frugal-Fallback": "gpt-4o,claude-sonnet-4-20250514,gemini-2.5-flas
```

If the routed model errors, Frugal walks the chain.
To bound latency and cost, Frugal attempts at most the first 3 fallback models.

## Supported models

Expand Down
62 changes: 61 additions & 1 deletion cmd/frugal/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import (
"log"
"net/http"
"os"
"strconv"
"time"

"github.com/go-chi/chi/v5"

Expand Down Expand Up @@ -80,6 +82,10 @@ func main() {
// Build classifier and router
cls := classifier.NewRuleBased()
modelEntries, thresholds := router.BuildTaxonomy(cfg)
modelEntries = filterRegisteredModels(modelEntries, registry)
if len(modelEntries) == 0 {
log.Fatal("no routable models available for registered providers")
}
rtr := router.New(modelEntries, thresholds)

// Build HTTP handler
Expand All @@ -104,16 +110,70 @@ func main() {
addr = a
}

server := newHTTPServer(addr, r)

log.Printf("frugal listening on %s", addr)
if err := http.ListenAndServe(addr, r); err != nil {
if err := server.ListenAndServe(); err != nil {
log.Fatalf("server error: %v", err)
}
}

func newHTTPServer(addr string, handler http.Handler) *http.Server {
return &http.Server{
Addr: addr,
Handler: handler,
ReadHeaderTimeout: envDurationOrDefault("FRUGAL_READ_HEADER_TIMEOUT", 5*time.Second),
ReadTimeout: envDurationOrDefault("FRUGAL_READ_TIMEOUT", 15*time.Second),
WriteTimeout: envDurationOrDefault("FRUGAL_WRITE_TIMEOUT", 120*time.Second),
IdleTimeout: envDurationOrDefault("FRUGAL_IDLE_TIMEOUT", 60*time.Second),
MaxHeaderBytes: envIntOrDefault("FRUGAL_MAX_HEADER_BYTES", http.DefaultMaxHeaderBytes),
}
}

func envDurationOrDefault(key string, fallback time.Duration) time.Duration {
value := os.Getenv(key)
if value == "" {
return fallback
}

parsed, err := time.ParseDuration(value)
if err != nil || parsed <= 0 {
log.Printf("warning: invalid %s=%q, using default %s", key, value, fallback)
return fallback
}

return parsed
}

func envIntOrDefault(key string, fallback int) int {
value := os.Getenv(key)
if value == "" {
return fallback
}

parsed, err := strconv.Atoi(value)
if err != nil || parsed <= 0 {
log.Printf("warning: invalid %s=%q, using default %d", key, value, fallback)
return fallback
}

return parsed
}

func modelNames(pc config.ProviderConfig) []string {
names := make([]string, 0, len(pc.Models))
for name := range pc.Models {
names = append(names, name)
}
return names
}

func filterRegisteredModels(entries []router.ModelEntry, registry *provider.Registry) []router.ModelEntry {
filtered := make([]router.ModelEntry, 0, len(entries))
for _, entry := range entries {
if _, err := registry.Resolve(entry.Name); err == nil {
filtered = append(filtered, entry)
}
}
return filtered
}
138 changes: 138 additions & 0 deletions cmd/frugal/main_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
package main

import (
"context"
"net/http"
"testing"
"time"

"github.com/frugalsh/frugal/internal/provider"
"github.com/frugalsh/frugal/internal/router"
"github.com/frugalsh/frugal/internal/types"
)

type testProvider struct {
name string
models []string
}

func (p *testProvider) Name() string { return p.name }
func (p *testProvider) Models() []string { return p.models }

func (p *testProvider) ChatCompletion(_ context.Context, _ string, _ *types.ChatCompletionRequest) (*types.ChatCompletionResponse, error) {
return &types.ChatCompletionResponse{}, nil
}

func (p *testProvider) ChatCompletionStream(_ context.Context, _ string, _ *types.ChatCompletionRequest) (<-chan provider.StreamChunk, error) {
ch := make(chan provider.StreamChunk)
close(ch)
return ch, nil
}

func TestFilterRegisteredModels(t *testing.T) {
reg := provider.NewRegistry()
reg.Register(&testProvider{name: "openai", models: []string{"gpt-4o-mini"}})

entries := []router.ModelEntry{
{Name: "gpt-4o-mini", Provider: "openai"},
{Name: "claude-sonnet-4-20250514", Provider: "anthropic"},
}

filtered := filterRegisteredModels(entries, reg)
if got := len(filtered); got != 1 {
t.Fatalf("expected 1 registered model, got %d", got)
}
if filtered[0].Name != "gpt-4o-mini" {
t.Fatalf("expected gpt-4o-mini to remain, got %s", filtered[0].Name)
}
}

func TestNewHTTPServerDefaults(t *testing.T) {
t.Setenv("FRUGAL_READ_HEADER_TIMEOUT", "")
t.Setenv("FRUGAL_READ_TIMEOUT", "")
t.Setenv("FRUGAL_WRITE_TIMEOUT", "")
t.Setenv("FRUGAL_IDLE_TIMEOUT", "")
t.Setenv("FRUGAL_MAX_HEADER_BYTES", "")

srv := newHTTPServer(":8080", http.NewServeMux())

if srv.ReadHeaderTimeout != 5*time.Second {
t.Fatalf("expected default read header timeout 5s, got %s", srv.ReadHeaderTimeout)
}
if srv.ReadTimeout != 15*time.Second {
t.Fatalf("expected default read timeout 15s, got %s", srv.ReadTimeout)
}
if srv.WriteTimeout != 120*time.Second {
t.Fatalf("expected default write timeout 120s, got %s", srv.WriteTimeout)
}
if srv.IdleTimeout != 60*time.Second {
t.Fatalf("expected default idle timeout 60s, got %s", srv.IdleTimeout)
}
if srv.MaxHeaderBytes != http.DefaultMaxHeaderBytes {
t.Fatalf("expected default max header bytes %d, got %d", http.DefaultMaxHeaderBytes, srv.MaxHeaderBytes)
}
}

func TestNewHTTPServerEnvOverrides(t *testing.T) {
t.Setenv("FRUGAL_READ_HEADER_TIMEOUT", "6s")
t.Setenv("FRUGAL_READ_TIMEOUT", "20s")
t.Setenv("FRUGAL_WRITE_TIMEOUT", "150s")
t.Setenv("FRUGAL_IDLE_TIMEOUT", "75s")
t.Setenv("FRUGAL_MAX_HEADER_BYTES", "65536")

srv := newHTTPServer(":8080", http.NewServeMux())

if srv.ReadHeaderTimeout != 6*time.Second {
t.Fatalf("expected read header timeout 6s, got %s", srv.ReadHeaderTimeout)
}
if srv.ReadTimeout != 20*time.Second {
t.Fatalf("expected read timeout 20s, got %s", srv.ReadTimeout)
}
if srv.WriteTimeout != 150*time.Second {
t.Fatalf("expected write timeout 150s, got %s", srv.WriteTimeout)
}
if srv.IdleTimeout != 75*time.Second {
t.Fatalf("expected idle timeout 75s, got %s", srv.IdleTimeout)
}
if srv.MaxHeaderBytes != 65536 {
t.Fatalf("expected max header bytes 65536, got %d", srv.MaxHeaderBytes)
}
}

func TestEnvDurationOrDefaultInvalidValues(t *testing.T) {
const key = "FRUGAL_TIMEOUT_TEST"

t.Setenv(key, "not-a-duration")
if got := envDurationOrDefault(key, 3*time.Second); got != 3*time.Second {
t.Fatalf("expected fallback for invalid duration, got %s", got)
}

t.Setenv(key, "0s")
if got := envDurationOrDefault(key, 3*time.Second); got != 3*time.Second {
t.Fatalf("expected fallback for zero duration, got %s", got)
}

t.Setenv(key, "-2s")
if got := envDurationOrDefault(key, 3*time.Second); got != 3*time.Second {
t.Fatalf("expected fallback for negative duration, got %s", got)
}
}

func TestEnvIntOrDefaultInvalidValues(t *testing.T) {
const key = "FRUGAL_INT_TEST"

t.Setenv(key, "not-an-int")
if got := envIntOrDefault(key, 1234); got != 1234 {
t.Fatalf("expected fallback for invalid int, got %d", got)
}

t.Setenv(key, "0")
if got := envIntOrDefault(key, 1234); got != 1234 {
t.Fatalf("expected fallback for zero int, got %d", got)
}

t.Setenv(key, "-10")
if got := envIntOrDefault(key, 1234); got != 1234 {
t.Fatalf("expected fallback for negative int, got %d", got)
}
}
30 changes: 24 additions & 6 deletions cmd/frugal/wrap.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package main

import (
"fmt"
"io"
"log"
"net"
"net/http"
Expand Down Expand Up @@ -47,6 +48,11 @@ func runWrap(configPath string, args []string) int {

cls := classifier.NewRuleBased()
modelEntries, thresholds := router.BuildTaxonomy(cfg)
modelEntries = filterRegisteredModels(modelEntries, registry)
if len(modelEntries) == 0 {
fmt.Fprintln(os.Stderr, "frugal: no routable models available for registered providers")
return 1
}
rtr := router.New(modelEntries, thresholds)
h := proxy.NewHandler(cls, rtr, registry)

Expand Down Expand Up @@ -77,7 +83,11 @@ func runWrap(configPath string, args []string) int {
}()

// Wait for proxy to be ready
waitForReady(fmt.Sprintf("http://127.0.0.1:%d/health", port))
if err := waitForReady(fmt.Sprintf("http://127.0.0.1:%d/health", port), 2*time.Second); err != nil {
fmt.Fprintf(os.Stderr, "frugal: proxy failed health check: %v\n", err)
server.Close()
return 1
}

fmt.Fprintf(os.Stderr, "frugal: proxy running on :%d → routing across %d models\n", port, len(registry.AllModels()))

Expand Down Expand Up @@ -144,13 +154,21 @@ func injectEnv(environ []string, baseURL string) []string {
return out
}

func waitForReady(url string) {
for i := 0; i < 50; i++ {
resp, err := http.Get(url)
func waitForReady(url string, timeout time.Duration) error {
client := &http.Client{Timeout: 200 * time.Millisecond}
deadline := time.Now().Add(timeout)

for time.Now().Before(deadline) {
resp, err := client.Get(url)
if err == nil {
io.Copy(io.Discard, resp.Body)
resp.Body.Close()
return
if resp.StatusCode >= http.StatusOK && resp.StatusCode < http.StatusMultipleChoices {
return nil
}
}
time.Sleep(10 * time.Millisecond)
time.Sleep(25 * time.Millisecond)
}

return fmt.Errorf("timed out waiting for %s after %s", url, timeout)
}
43 changes: 43 additions & 0 deletions cmd/frugal/wrap_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
package main

import (
"fmt"
"net"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
"time"
)

func TestWaitForReadyReturnsAfterHealthyStatus(t *testing.T) {
var attempts int32
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if atomic.AddInt32(&attempts, 1) < 3 {
w.WriteHeader(http.StatusServiceUnavailable)
_, _ = w.Write([]byte("not ready"))
return
}
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("ok"))
}))
defer ts.Close()

if err := waitForReady(ts.URL, 500*time.Millisecond); err != nil {
t.Fatalf("expected readiness check to succeed, got error: %v", err)
}
}

func TestWaitForReadyTimesOutWhenUnreachable(t *testing.T) {
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("failed to reserve local port: %v", err)
}
addr := ln.Addr().String()
_ = ln.Close()

url := fmt.Sprintf("http://%s/health", addr)
if err := waitForReady(url, 150*time.Millisecond); err == nil {
t.Fatalf("expected timeout error for unreachable endpoint")
}
}
5 changes: 4 additions & 1 deletion internal/config/config.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package config

import (
"bytes"
"fmt"
"os"

Expand Down Expand Up @@ -53,7 +54,9 @@ func Load(path string) (*Config, error) {
}

var cfg Config
if err := yaml.Unmarshal(data, &cfg); err != nil {
dec := yaml.NewDecoder(bytes.NewReader(data))
dec.KnownFields(true)
if err := dec.Decode(&cfg); err != nil {
return nil, fmt.Errorf("parsing config: %w", err)
}

Expand Down
Loading