diff --git a/internal/embedded/dashboard.go b/internal/embedded/dashboard.go new file mode 100644 index 0000000..2502101 --- /dev/null +++ b/internal/embedded/dashboard.go @@ -0,0 +1,12 @@ +package embedded + +import ( + "embed" +) + +//go:embed dashboard/proxy.json +var dashboardFS embed.FS + +func DashboardJSON() ([]byte, error) { + return dashboardFS.ReadFile("dashboard/proxy.json") +} diff --git a/internal/embedded/dashboard/proxy.json b/internal/embedded/dashboard/proxy.json new file mode 100644 index 0000000..f389f7b --- /dev/null +++ b/internal/embedded/dashboard/proxy.json @@ -0,0 +1,450 @@ +{ + "kind": "Dashboard", + "metadata": { + "name": "proxy", + "createdAt": "2026-04-14T19:47:48.013238204Z", + "updatedAt": "2026-04-14T19:49:30.874125459Z", + "version": 1, + "project": "anthropic-proxy" + }, + "spec": { + "display": { + "name": "Anthropic Proxy" + }, + "datasources": { + "vm": { + "default": true, + "plugin": { + "kind": "PrometheusDatasource", + "spec": { + "directUrl": "http://localhost:9428" + } + } + } + }, + "panels": { + "latency": { + "kind": "Panel", + "spec": { + "display": { + "name": "Latency" + }, + "plugin": { + "kind": "TimeSeriesChart", + "spec": { + "legend": { + "position": "bottom" + }, + "yAxis": { + "format": { + "unit": "milliseconds" + } + } + } + }, + "queries": [ + { + "kind": "TimeSeriesQuery", + "spec": { + "plugin": { + "kind": "PrometheusTimeSeriesQuery", + "spec": { + "datasource": { + "kind": "PrometheusDatasource", + "name": "vm" + }, + "query": "histogram_quantile(0.50, rate(proxy_request_duration_ms_milliseconds_bucket[5m]))", + "seriesNameFormat": "p50" + } + } + } + }, + { + "kind": "TimeSeriesQuery", + "spec": { + "plugin": { + "kind": "PrometheusTimeSeriesQuery", + "spec": { + "datasource": { + "kind": "PrometheusDatasource", + "name": "vm" + }, + "query": "histogram_quantile(0.95, rate(proxy_request_duration_ms_milliseconds_bucket[5m]))", + "seriesNameFormat": "p95" + } + } + } + }, + { + "kind": "TimeSeriesQuery", + "spec": { + "plugin": { + "kind": "PrometheusTimeSeriesQuery", + "spec": { + "datasource": { + "kind": "PrometheusDatasource", + "name": "vm" + }, + "query": "histogram_quantile(0.99, rate(proxy_request_duration_ms_milliseconds_bucket[5m]))", + "seriesNameFormat": "p99" + } + } + } + } + ] + } + }, + "request_rate": { + "kind": "Panel", + "spec": { + "display": { + "name": "Request Rate" + }, + "plugin": { + "kind": "TimeSeriesChart", + "spec": { + "legend": { + "position": "bottom" + } + } + }, + "queries": [ + { + "kind": "TimeSeriesQuery", + "spec": { + "plugin": { + "kind": "PrometheusTimeSeriesQuery", + "spec": { + "datasource": { + "kind": "PrometheusDatasource", + "name": "vm" + }, + "query": "rate(proxy_request_count_total[5m])", + "seriesNameFormat": "req/s" + } + } + } + } + ] + } + }, + "token_rate": { + "kind": "Panel", + "spec": { + "display": { + "name": "Token Rate" + }, + "plugin": { + "kind": "TimeSeriesChart", + "spec": { + "legend": { + "position": "bottom" + } + } + }, + "queries": [ + { + "kind": "TimeSeriesQuery", + "spec": { + "plugin": { + "kind": "PrometheusTimeSeriesQuery", + "spec": { + "datasource": { + "kind": "PrometheusDatasource", + "name": "vm" + }, + "query": "rate(proxy_tokens_input_total[5m]) * 60", + "seriesNameFormat": "input/min" + } + } + } + }, + { + "kind": "TimeSeriesQuery", + "spec": { + "plugin": { + "kind": "PrometheusTimeSeriesQuery", + "spec": { + "datasource": { + "kind": "PrometheusDatasource", + "name": "vm" + }, + "query": "rate(proxy_tokens_output_total[5m]) * 60", + "seriesNameFormat": "output/min" + } + } + } + } + ] + } + }, + "tokens_5h": { + "kind": "Panel", + "spec": { + "display": { + "name": "5h Tokens" + }, + "plugin": { + "kind": "StatChart", + "spec": { + "calculation": "last", + "format": { + "unit": "decimal" + }, + "sparkline": {} + } + }, + "queries": [ + { + "kind": "TimeSeriesQuery", + "spec": { + "plugin": { + "kind": "PrometheusTimeSeriesQuery", + "spec": { + "datasource": { + "kind": "PrometheusDatasource", + "name": "vm" + }, + "query": "increase(proxy_tokens_output_total[3h])" + } + } + } + } + ] + } + }, + "tokens_7d": { + "kind": "Panel", + "spec": { + "display": { + "name": "7d Tokens" + }, + "plugin": { + "kind": "StatChart", + "spec": { + "calculation": "last", + "format": { + "unit": "decimal" + }, + "sparkline": {} + } + }, + "queries": [ + { + "kind": "TimeSeriesQuery", + "spec": { + "plugin": { + "kind": "PrometheusTimeSeriesQuery", + "spec": { + "datasource": { + "kind": "PrometheusDatasource", + "name": "vm" + }, + "query": "increase(proxy_tokens_output_total[9h])" + } + } + } + } + ] + } + }, + "util_5h": { + "kind": "Panel", + "spec": { + "display": { + "name": "5h Utilization" + }, + "plugin": { + "kind": "GaugeChart", + "spec": { + "calculation": "last", + "format": { + "unit": "percent" + }, + "thresholds": { + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "orange", + "value": 70 + }, + { + "color": "red", + "value": 90 + } + ] + } + } + }, + "queries": [ + { + "kind": "TimeSeriesQuery", + "spec": { + "plugin": { + "kind": "PrometheusTimeSeriesQuery", + "spec": { + "datasource": { + "kind": "PrometheusDatasource", + "name": "vm" + }, + "query": "proxy_usage_utilization{window=\"5h\"}" + } + } + } + } + ] + } + }, + "util_7d": { + "kind": "Panel", + "spec": { + "display": { + "name": "7d Utilization" + }, + "plugin": { + "kind": "GaugeChart", + "spec": { + "calculation": "last", + "format": { + "unit": "percent" + }, + "thresholds": { + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "orange", + "value": 70 + }, + { + "color": "red", + "value": 90 + } + ] + } + } + }, + "queries": [ + { + "kind": "TimeSeriesQuery", + "spec": { + "plugin": { + "kind": "PrometheusTimeSeriesQuery", + "spec": { + "datasource": { + "kind": "PrometheusDatasource", + "name": "vm" + }, + "query": "proxy_usage_utilization{window=\"7d\"}" + } + } + } + } + ] + } + } + }, + "layouts": [ + { + "kind": "Grid", + "spec": { + "display": { + "title": "Utilization" + }, + "items": [ + { + "x": 0, + "y": 0, + "width": 6, + "height": 5, + "content": { + "$ref": "#/spec/panels/util_5h" + } + }, + { + "x": 6, + "y": 0, + "width": 6, + "height": 5, + "content": { + "$ref": "#/spec/panels/util_7d" + } + }, + { + "x": 12, + "y": 0, + "width": 6, + "height": 5, + "content": { + "$ref": "#/spec/panels/tokens_5h" + } + }, + { + "x": 18, + "y": 0, + "width": 6, + "height": 5, + "content": { + "$ref": "#/spec/panels/tokens_7d" + } + } + ] + } + }, + { + "kind": "Grid", + "spec": { + "display": { + "title": "Traffic" + }, + "items": [ + { + "x": 0, + "y": 0, + "width": 12, + "height": 8, + "content": { + "$ref": "#/spec/panels/request_rate" + } + }, + { + "x": 12, + "y": 0, + "width": 12, + "height": 8, + "content": { + "$ref": "#/spec/panels/latency" + } + } + ] + } + }, + { + "kind": "Grid", + "spec": { + "display": { + "title": "Tokens" + }, + "items": [ + { + "x": 0, + "y": 0, + "width": 24, + "height": 8, + "content": { + "$ref": "#/spec/panels/token_rate" + } + } + ] + } + } + ], + "duration": "1h", + "refreshInterval": "10s" + } +} diff --git a/internal/embedded/download.go b/internal/embedded/download.go new file mode 100644 index 0000000..cd408ae --- /dev/null +++ b/internal/embedded/download.go @@ -0,0 +1,155 @@ +package embedded + +import ( + "archive/tar" + "compress/gzip" + "fmt" + "io" + "net/http" + "os" + "os/exec" + "path/filepath" + "runtime" + + "github.com/rs/zerolog/log" +) + +const cacheDir = ".cache/anthropic-proxy/bin" + +var downloads = map[string]struct { + urlTemplate string + version string + extractName string +}{ + "victoria-metrics": { + urlTemplate: "https://github.com/VictoriaMetrics/VictoriaMetrics/releases/download/v%s/victoria-metrics-%s-v%s.tar.gz", + version: "1.118.0", + extractName: "victoria-metrics-prod", + }, + "perses": { + urlTemplate: "https://github.com/perses/perses/releases/download/v%s/perses_%s_%s_%s.tar.gz", + version: "0.53.1", + }, +} + +func ensureBinary(name, configPath, configBinDir string) (string, error) { + if configPath != "" { + if p, err := exec.LookPath(configPath); err == nil { + return p, nil + } + } + + if p, err := exec.LookPath(name); err == nil { + return p, nil + } + + binDir := configBinDir + if binDir == "" { + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("get home dir: %w", err) + } + binDir = filepath.Join(home, cacheDir) + } + cachedPath := filepath.Join(binDir, name) + + if _, err := os.Stat(cachedPath); err == nil { + return cachedPath, nil + } + + log.Info().Str("binary", name).Msg("downloading binary (first run)") + + if err := os.MkdirAll(binDir, 0o755); err != nil { + return "", fmt.Errorf("create cache dir: %w", err) + } + + url, err := downloadURL(name) + if err != nil { + return "", err + } + + if err := extractAll(url, binDir); err != nil { + return "", fmt.Errorf("download %s: %w", name, err) + } + + d := downloads[name] + if d.extractName != "" { + oldPath := filepath.Join(binDir, d.extractName) + if _, err := os.Stat(oldPath); err == nil { + os.Rename(oldPath, cachedPath) + } + } + + if _, err := os.Stat(cachedPath); err != nil { + return "", fmt.Errorf("binary %s not found after extraction", name) + } + + log.Info().Str("binary", name).Str("path", cachedPath).Msg("binary downloaded") + return cachedPath, nil +} + +func downloadURL(name string) (string, error) { + goarch := runtime.GOARCH + goos := runtime.GOOS + + d, ok := downloads[name] + if !ok { + return "", fmt.Errorf("unknown binary: %s", name) + } + + switch name { + case "victoria-metrics": + vmOS := fmt.Sprintf("%s-%s", goos, goarch) + return fmt.Sprintf(d.urlTemplate, d.version, vmOS, d.version), nil + case "perses": + return fmt.Sprintf(d.urlTemplate, d.version, d.version, goos, goarch), nil + } + return "", fmt.Errorf("unknown binary: %s", name) +} + +func extractAll(url, destDir string) error { + resp, err := http.Get(url) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + return fmt.Errorf("download failed: HTTP %d from %s", resp.StatusCode, url) + } + + gz, err := gzip.NewReader(resp.Body) + if err != nil { + return fmt.Errorf("gzip reader: %w", err) + } + defer gz.Close() + + tr := tar.NewReader(gz) + for { + hdr, err := tr.Next() + if err == io.EOF { + return nil + } + if err != nil { + return fmt.Errorf("read tar: %w", err) + } + + target := filepath.Join(destDir, hdr.Name) + switch hdr.Typeflag { + case tar.TypeDir: + os.MkdirAll(target, 0o755) + case tar.TypeReg: + os.MkdirAll(filepath.Dir(target), 0o755) + mode := os.FileMode(hdr.Mode) + if mode == 0 { + mode = 0o644 + } + out, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, mode) + if err != nil { + return err + } + io.Copy(out, tr) + out.Close() + } + } +} diff --git a/internal/embedded/perses.go b/internal/embedded/perses.go new file mode 100644 index 0000000..1f80d87 --- /dev/null +++ b/internal/embedded/perses.go @@ -0,0 +1,149 @@ +package embedded + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + + "github.com/fujin/anthropic-proxy/internal/config" + "github.com/rs/zerolog/log" +) + +type Perses struct { + cfg config.EmbeddedConfig + proxyPort int + cmd *exec.Cmd + tmpDir string +} + +func NewPerses(cfg config.EmbeddedConfig, proxyPort int) *Perses { + return &Perses{cfg: cfg, proxyPort: proxyPort} +} + +func (p *Perses) Start() error { + bin, err := ensureBinary("perses", p.cfg.PersesBinary, p.cfg.BinDir) + if err != nil { + return fmt.Errorf("perses: %w", err) + } + + p.tmpDir, err = os.MkdirTemp("", "perses-*") + if err != nil { + return fmt.Errorf("create temp dir: %w", err) + } + + if err := p.writeServerConfig(); err != nil { + return fmt.Errorf("write server config: %w", err) + } + if err := p.writeDatasourceProvision(); err != nil { + return fmt.Errorf("write datasource provision: %w", err) + } + if err := p.writeDashboardProvision(); err != nil { + return fmt.Errorf("write dashboard provision: %w", err) + } + + p.cmd = exec.Command(bin, + "--config", filepath.Join(p.tmpDir, "config.yaml"), + "-web.listen-address", fmt.Sprintf(":%d", p.cfg.Port), + ) + p.cmd.Dir = filepath.Dir(bin) + p.cmd.Stdout = &logWriter{level: "info", component: "perses"} + p.cmd.Stderr = &logWriter{level: "error", component: "perses"} + + if err := p.cmd.Start(); err != nil { + return fmt.Errorf("start perses: %w", err) + } + + log.Info(). + Str("binary", bin). + Int("port", p.cfg.Port). + Str("config", p.tmpDir). + Msg("perses started") + + return nil +} + +func (p *Perses) Stop() { + if p.cmd != nil && p.cmd.Process != nil { + _ = p.cmd.Process.Kill() + _ = p.cmd.Wait() + } + if p.tmpDir != "" { + _ = os.RemoveAll(p.tmpDir) + } +} + +func (p *Perses) Running() bool { + return p.cmd != nil && p.cmd.Process != nil && p.cmd.ProcessState == nil +} + +func (p *Perses) writeServerConfig() error { + provisionDir := filepath.Join(p.tmpDir, "provisions") + if err := os.MkdirAll(filepath.Join(provisionDir, "datasources"), 0o755); err != nil { + return err + } + if err := os.MkdirAll(filepath.Join(provisionDir, "dashboards"), 0o755); err != nil { + return err + } + + cfg := fmt.Sprintf(`provisioning: + interval: 1m + folders: + - %s +database: + file: + folder: %s/data + extension: json +security: + readonly: false + enable_auth: false +`, provisionDir, p.tmpDir) + + return os.WriteFile(filepath.Join(p.tmpDir, "config.yaml"), []byte(cfg), 0o644) +} + +func (p *Perses) writeDatasourceProvision() error { + ds := fmt.Sprintf(`kind: Datasource +metadata: + name: victoria-metrics + project: anthropic-proxy +spec: + default: true + plugin: + kind: PrometheusDatasource + spec: + directUrl: http://localhost:%d +`, p.cfg.VMPort) + + return os.WriteFile( + filepath.Join(p.tmpDir, "provisions", "datasources", "vm.yaml"), + []byte(ds), 0o644, + ) +} + +func (p *Perses) writeDashboardProvision() error { + dashData, err := DashboardJSON() + if err != nil { + return err + } + return os.WriteFile( + filepath.Join(p.tmpDir, "provisions", "dashboards", "proxy.json"), + dashData, 0o644, + ) +} + +type logWriter struct { + level string + component string +} + +func (w *logWriter) Write(p []byte) (n int, err error) { + msg := string(p) + switch w.level { + case "error": + log.Error().Str("component", w.component).Msg(msg) + default: + log.Debug().Str("component", w.component).Msg(msg) + } + return len(p), nil +} diff --git a/internal/embedded/vm.go b/internal/embedded/vm.go new file mode 100644 index 0000000..012eee9 --- /dev/null +++ b/internal/embedded/vm.go @@ -0,0 +1,88 @@ +package embedded + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + + "github.com/fujin/anthropic-proxy/internal/config" + "github.com/rs/zerolog/log" +) + +type VM struct { + cfg config.EmbeddedConfig + proxyPort int + cmd *exec.Cmd + tmpDir string +} + +func NewVM(cfg config.EmbeddedConfig, proxyPort int) *VM { + return &VM{cfg: cfg, proxyPort: proxyPort} +} + +func (v *VM) Start() error { + bin, err := ensureBinary("victoria-metrics", v.cfg.VMBinary, v.cfg.BinDir) + if err != nil { + return fmt.Errorf("victoria-metrics: %w", err) + } + + v.tmpDir, err = os.MkdirTemp("", "vm-*") + if err != nil { + return fmt.Errorf("create temp dir: %w", err) + } + + scrapeConfig := fmt.Sprintf(`global: + scrape_interval: 15s +scrape_configs: + - job_name: anthropic-proxy + static_configs: + - targets: + - localhost:%d +`, v.proxyPort) + + scrapePath := filepath.Join(v.tmpDir, "scrape.yaml") + if err := os.WriteFile(scrapePath, []byte(scrapeConfig), 0o644); err != nil { + return fmt.Errorf("write scrape config: %w", err) + } + + dataPath := filepath.Join(v.tmpDir, "data") + if err := os.MkdirAll(dataPath, 0o755); err != nil { + return fmt.Errorf("create data dir: %w", err) + } + + v.cmd = exec.Command(bin, + "-storageDataPath", dataPath, + "-retentionPeriod", "7d", + "-httpListenAddr", fmt.Sprintf(":%d", v.cfg.VMPort), + "-promscrape.config", scrapePath, + ) + v.cmd.Stdout = &logWriter{level: "info", component: "victoria-metrics"} + v.cmd.Stderr = &logWriter{level: "error", component: "victoria-metrics"} + + if err := v.cmd.Start(); err != nil { + return fmt.Errorf("start victoria-metrics: %w", err) + } + + log.Info(). + Str("binary", bin). + Int("port", v.cfg.VMPort). + Int("scrape_target_port", v.proxyPort). + Msg("victoria-metrics started") + + return nil +} + +func (v *VM) Stop() { + if v.cmd != nil && v.cmd.Process != nil { + _ = v.cmd.Process.Kill() + _ = v.cmd.Wait() + } + if v.tmpDir != "" { + _ = os.RemoveAll(v.tmpDir) + } +} + +func (v *VM) Running() bool { + return v.cmd != nil && v.cmd.Process != nil && v.cmd.ProcessState == nil +}