feat: ollama-proxy — multi-account Ollama Cloud reverse proxy
Go reverse proxy for ollama.com that balances requests across multiple API keys with round-robin and failover on 429/5xx. Exposes both native Ollama API (/api/*) and OpenAI-compatible (/v1/*) passthrough. - Round-robin balancer with per-account cooldown (60s default) - Pre-stream failover: 429 → cooldown + next account; 5xx → next account - Streaming invariant: once 2xx starts streaming, no account switch - SSE (text/event-stream) and NDJSON passthrough with http.Flusher - CLI: accounts add/list/remove/set-base-url, serve, version - Accounts stored in ~/.config/ollama-proxy/accounts.json (chmod 0600) - systemd unit (User=dueattendant149, 127.0.0.1:11435, Restart=always) - 43 tests (unit + integration with httptest upstream) - opencode integration: custom provider 'ocp' with explicit model list - Requires NO_PROXY=127.0.0.1,localhost when HTTP_PROXY is set
This commit is contained in:
commit
3963eede70
23 changed files with 2534 additions and 0 deletions
223
internal/cli/accounts.go
Normal file
223
internal/cli/accounts.go
Normal file
|
|
@ -0,0 +1,223 @@
|
|||
package cli
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/Atte149/ollama-proxy/internal/config"
|
||||
"golang.org/x/term"
|
||||
)
|
||||
|
||||
// RunAccounts dispatches to the accounts subcommand.
|
||||
func RunAccounts(args []string) int {
|
||||
if len(args) < 1 {
|
||||
printAccountsUsage()
|
||||
return 1
|
||||
}
|
||||
switch args[0] {
|
||||
case "add":
|
||||
return accountsAdd(args[1:])
|
||||
case "list":
|
||||
return accountsList(args[1:])
|
||||
case "remove", "rm":
|
||||
return accountsRemove(args[1:])
|
||||
case "set-base-url":
|
||||
return accountsSetBaseURL(args[1:])
|
||||
case "-h", "--help", "help":
|
||||
printAccountsUsage()
|
||||
return 0
|
||||
default:
|
||||
fmt.Fprintf(os.Stderr, "unknown accounts subcommand: %s\n\n", args[0])
|
||||
printAccountsUsage()
|
||||
return 2
|
||||
}
|
||||
}
|
||||
|
||||
func printAccountsUsage() {
|
||||
fmt.Print(`Usage:
|
||||
ollama-proxy accounts add [API_KEY] [--name <alias>]
|
||||
ollama-proxy accounts list
|
||||
ollama-proxy accounts remove <id|name>
|
||||
ollama-proxy accounts set-base-url <url>
|
||||
`)
|
||||
}
|
||||
|
||||
// accountsAdd implements `accounts add [API_KEY] [--name <alias>]`.
|
||||
func accountsAdd(args []string) int {
|
||||
var apiKey, name string
|
||||
for i := 0; i < len(args); i++ {
|
||||
switch args[i] {
|
||||
case "--name":
|
||||
if i+1 >= len(args) {
|
||||
fmt.Fprintln(os.Stderr, "--name requires an argument")
|
||||
return 2
|
||||
}
|
||||
name = args[i+1]
|
||||
i++
|
||||
case "-h", "--help":
|
||||
fmt.Println("usage: accounts add [API_KEY] [--name <alias>]")
|
||||
return 0
|
||||
default:
|
||||
if apiKey == "" {
|
||||
apiKey = args[i]
|
||||
} else {
|
||||
fmt.Fprintf(os.Stderr, "unexpected argument: %s\n", args[i])
|
||||
return 2
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if name == "" {
|
||||
name = promptString("Account name (alias)", "acct"+config.NewID()[:4])
|
||||
}
|
||||
if err := config.ValidateName(name); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "invalid name:", err)
|
||||
return 2
|
||||
}
|
||||
|
||||
if apiKey == "" {
|
||||
fmt.Print("Enter Ollama Cloud API key (input hidden): ")
|
||||
b, err := term.ReadPassword(int(os.Stdin.Fd()))
|
||||
fmt.Println()
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "read key:", err)
|
||||
return 1
|
||||
}
|
||||
apiKey = strings.TrimSpace(string(b))
|
||||
}
|
||||
if apiKey == "" {
|
||||
fmt.Fprintln(os.Stderr, "empty API key, nothing to add")
|
||||
return 2
|
||||
}
|
||||
|
||||
if err := config.EnsureAccountsDir(); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "create config dir:", err)
|
||||
return 1
|
||||
}
|
||||
af, err := config.LoadAccounts()
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "load accounts:", err)
|
||||
return 1
|
||||
}
|
||||
if af.FindByName(name) != nil {
|
||||
fmt.Fprintf(os.Stderr, "an account named %q already exists\n", name)
|
||||
return 2
|
||||
}
|
||||
acct := config.Account{
|
||||
ID: config.NewID(),
|
||||
Name: name,
|
||||
APIKey: apiKey,
|
||||
Created: nowFn(),
|
||||
}
|
||||
af.Accounts = append(af.Accounts, acct)
|
||||
if err := af.Save(); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "save accounts:", err)
|
||||
return 1
|
||||
}
|
||||
fmt.Printf("added account %s (name=%s key=%s)\n", acct.ID[:8], acct.Name, maskKey(acct.APIKey))
|
||||
return 0
|
||||
}
|
||||
|
||||
// accountsList prints a table of accounts.
|
||||
func accountsList(args []string) int {
|
||||
af, err := config.LoadAccounts()
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "load accounts:", err)
|
||||
return 1
|
||||
}
|
||||
if len(af.Accounts) == 0 {
|
||||
fmt.Println("no accounts configured. Run: ollama-proxy accounts add")
|
||||
return 0
|
||||
}
|
||||
w := bufio.NewWriter(os.Stdout)
|
||||
defer w.Flush()
|
||||
fmt.Fprintf(w, "%-10s %-16s %-18s %-22s\n", "ID", "NAME", "KEY", "CREATED")
|
||||
fmt.Fprintf(w, "%-10s %-16s %-18s %-22s\n", strings.Repeat("-", 8), strings.Repeat("-", 14), strings.Repeat("-", 16), strings.Repeat("-", 20))
|
||||
for _, a := range af.Accounts {
|
||||
id := a.ID
|
||||
if len(id) > 8 {
|
||||
id = id[:8]
|
||||
}
|
||||
created := a.Created.Format("2006-01-02 15:04 MST")
|
||||
fmt.Fprintf(w, "%-10s %-16s %-18s %-22s\n", id, a.Name, maskKey(a.APIKey), created)
|
||||
}
|
||||
if af.BaseURL != "" {
|
||||
fmt.Fprintf(w, "\nDefault upstream: %s\n", af.BaseURL)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// accountsRemove removes an account by id or name.
|
||||
func accountsRemove(args []string) int {
|
||||
if len(args) < 1 || args[0] == "-h" || args[0] == "--help" {
|
||||
fmt.Println("usage: accounts remove <id|name>")
|
||||
if len(args) < 1 {
|
||||
return 2
|
||||
}
|
||||
return 0
|
||||
}
|
||||
ident := args[0]
|
||||
af, err := config.LoadAccounts()
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "load accounts:", err)
|
||||
return 1
|
||||
}
|
||||
if err := af.Remove(ident); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
return 1
|
||||
}
|
||||
if err := af.Save(); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "save:", err)
|
||||
return 1
|
||||
}
|
||||
fmt.Printf("removed account %s\n", ident)
|
||||
return 0
|
||||
}
|
||||
|
||||
// accountsSetBaseURL sets the default upstream URL stored in accounts.json.
|
||||
func accountsSetBaseURL(args []string) int {
|
||||
if len(args) < 1 {
|
||||
fmt.Println("usage: accounts set-base-url <url>")
|
||||
return 2
|
||||
}
|
||||
url := args[0]
|
||||
if err := config.EnsureAccountsDir(); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "create config dir:", err)
|
||||
return 1
|
||||
}
|
||||
af, err := config.LoadAccounts()
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "load accounts:", err)
|
||||
return 1
|
||||
}
|
||||
af.BaseURL = url
|
||||
if err := af.Save(); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "save:", err)
|
||||
return 1
|
||||
}
|
||||
fmt.Printf("default upstream set to %s\n", url)
|
||||
return 0
|
||||
}
|
||||
|
||||
// promptString reads a line from stdin, returning def when the user just hits
|
||||
// enter. Used for the optional account name prompt.
|
||||
func promptString(label, def string) string {
|
||||
fmt.Printf("%s [%s]: ", label, def)
|
||||
r := bufio.NewReader(os.Stdin)
|
||||
line, _ := r.ReadString('\n')
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
return def
|
||||
}
|
||||
return line
|
||||
}
|
||||
|
||||
// maskKey mirrors config.accounts.maskKey but is exposed here for display.
|
||||
func maskKey(key string) string {
|
||||
if len(key) <= 4 {
|
||||
return "****"
|
||||
}
|
||||
return "****" + key[len(key)-4:]
|
||||
}
|
||||
241
internal/cli/integration_test.go
Normal file
241
internal/cli/integration_test.go
Normal file
|
|
@ -0,0 +1,241 @@
|
|||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// findBinary locates the ollama-proxy executable built by `go test`. The test
|
||||
// binary is in the package dir; the project binary is one level up. We try both
|
||||
// and fall back to building one on the fly.
|
||||
func findBinary(t *testing.T) string {
|
||||
t.Helper()
|
||||
candidates := []string{
|
||||
filepath.Join("..", "..", "ollama-proxy"),
|
||||
"ollama-proxy",
|
||||
}
|
||||
for _, c := range candidates {
|
||||
if abs, err := filepath.Abs(c); err == nil {
|
||||
if fi, err := os.Stat(abs); err == nil && !fi.IsDir() {
|
||||
return abs
|
||||
}
|
||||
}
|
||||
}
|
||||
// Build it now into a temp path.
|
||||
out := filepath.Join(t.TempDir(), "ollama-proxy")
|
||||
cmd := exec.Command("go", "build", "-o", out, ".")
|
||||
cmd.Dir = filepath.Join("..", "..")
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
t.Fatalf("go build: %v\n%s", err, out)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// freePort picks an unused TCP port for the proxy to listen on.
|
||||
func freePort(t *testing.T) string {
|
||||
t.Helper()
|
||||
l, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer l.Close()
|
||||
return l.Addr().String()
|
||||
}
|
||||
|
||||
// startProxy starts the binary as a subprocess and returns a cancel function
|
||||
// that stops it.
|
||||
func startProxy(t *testing.T, bin, xdgDir, addr, upstream string) func() {
|
||||
t.Helper()
|
||||
cmd := exec.Command(bin, "serve",
|
||||
"--addr", addr,
|
||||
"--base-url", upstream,
|
||||
"--cooldown", "1s",
|
||||
"--retries", "3",
|
||||
"--log-level", "debug",
|
||||
)
|
||||
cmd.Env = append(os.Environ(),
|
||||
"XDG_CONFIG_HOME="+xdgDir,
|
||||
"OLLAMA_PROXY_LOG_LEVEL=debug",
|
||||
)
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = &stderr
|
||||
if err := cmd.Start(); err != nil {
|
||||
t.Fatalf("start proxy: %v", err)
|
||||
}
|
||||
// Wait for the port to accept connections.
|
||||
deadline := time.Now().Add(3 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
c, err := net.Dial("tcp", addr)
|
||||
if err == nil {
|
||||
c.Close()
|
||||
break
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
return func() {
|
||||
_ = cmd.Process.Signal(os.Interrupt)
|
||||
done := make(chan struct{})
|
||||
go func() { _ = cmd.Wait(); close(done) }()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(2 * time.Second):
|
||||
_ = cmd.Process.Kill()
|
||||
<-done
|
||||
}
|
||||
t.Logf("proxy stdout: %s", stdout.String())
|
||||
t.Logf("proxy stderr: %s", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestEndToEnd_ProxyForwardsToUpstream builds the binary and verifies the full
|
||||
// stack: account JSON → balancer → handler → real HTTP server → upstream.
|
||||
func TestEndToEnd_ProxyForwardsToUpstream(t *testing.T) {
|
||||
// Upstream that echoes the auth and path.
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(200)
|
||||
_, _ = fmt.Fprintf(w, `{"path":%q,"auth":%q}`, r.URL.Path, r.Header.Get("Authorization"))
|
||||
}))
|
||||
defer upstream.Close()
|
||||
|
||||
bin := findBinary(t)
|
||||
xdg := t.TempDir()
|
||||
// Write accounts.json directly via the binary's `accounts add`.
|
||||
for _, a := range []struct{ key, name string }{
|
||||
{"sk-keyAAAAAAAA", "a1"},
|
||||
{"sk-keyBBBBBBBB", "a2"},
|
||||
} {
|
||||
cmd := exec.Command(bin, "accounts", "add", a.key, "--name", a.name)
|
||||
cmd.Env = append(os.Environ(), "XDG_CONFIG_HOME="+xdg)
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
t.Fatalf("accounts add %s: %v\n%s", a.name, err, out)
|
||||
}
|
||||
}
|
||||
|
||||
addr := freePort(t)
|
||||
stop := startProxy(t, bin, xdg, addr, upstream.URL)
|
||||
defer stop()
|
||||
|
||||
resp, err := http.Get("http://" + addr + "/api/version")
|
||||
if err != nil {
|
||||
t.Fatalf("GET /api/version: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode != 200 {
|
||||
t.Fatalf("status = %d, body = %s", resp.StatusCode, body)
|
||||
}
|
||||
if !strings.Contains(string(body), "sk-key") {
|
||||
t.Errorf("body = %s, want account key forwarded", body)
|
||||
}
|
||||
if !strings.Contains(string(body), "/api/version") {
|
||||
t.Errorf("body = %s, want path echoed", body)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEndToEnd_SSEStreaming verifies SSE passthrough through the real binary.
|
||||
func TestEndToEnd_SSEStreaming(t *testing.T) {
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.Header().Set("Transfer-Encoding", "chunked")
|
||||
w.WriteHeader(200)
|
||||
flusher, _ := w.(http.Flusher)
|
||||
for i := 0; i < 3; i++ {
|
||||
_, _ = io.WriteString(w, "data: {\"i\":"+string(rune('0'+i))+"}\n\n")
|
||||
if flusher != nil {
|
||||
flusher.Flush()
|
||||
}
|
||||
}
|
||||
_, _ = io.WriteString(w, "data: [DONE]\n\n")
|
||||
if flusher != nil {
|
||||
flusher.Flush()
|
||||
}
|
||||
}))
|
||||
defer upstream.Close()
|
||||
|
||||
bin := findBinary(t)
|
||||
xdg := t.TempDir()
|
||||
cmd := exec.Command(bin, "accounts", "add", "sk-testkey", "--name", "a1")
|
||||
cmd.Env = append(os.Environ(), "XDG_CONFIG_HOME="+xdg)
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
t.Fatalf("accounts add: %v\n%s", err, out)
|
||||
}
|
||||
|
||||
addr := freePort(t)
|
||||
stop := startProxy(t, bin, xdg, addr, upstream.URL)
|
||||
defer stop()
|
||||
|
||||
resp, err := http.Post("http://"+addr+"/v1/chat/completions", "application/json", strings.NewReader(`{"stream":true}`))
|
||||
if err != nil {
|
||||
t.Fatalf("POST: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
if !strings.Contains(string(body), "[DONE]") {
|
||||
t.Errorf("body = %s, want [DONE]", body)
|
||||
}
|
||||
if !strings.Contains(string(body), `"i":0`) || !strings.Contains(string(body), `"i":1`) {
|
||||
t.Errorf("body = %s, missing chunks", body)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEndToEnd_FailoverOn429 verifies the binary fails over between accounts.
|
||||
func TestEndToEnd_FailoverOn429(t *testing.T) {
|
||||
// Upstream that 429s for the first account key and 200s for the second.
|
||||
var firstSeen string
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
auth := r.Header.Get("Authorization")
|
||||
if firstSeen == "" {
|
||||
firstSeen = auth
|
||||
}
|
||||
if auth == "Bearer sk-bad" {
|
||||
http.Error(w, "rate limited", 429)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(200)
|
||||
_, _ = io.WriteString(w, `{"ok":true}`)
|
||||
}))
|
||||
defer upstream.Close()
|
||||
|
||||
bin := findBinary(t)
|
||||
xdg := t.TempDir()
|
||||
for _, a := range []struct{ key, name string }{
|
||||
{"sk-bad", "bad"},
|
||||
{"sk-good", "good"},
|
||||
} {
|
||||
cmd := exec.Command(bin, "accounts", "add", a.key, "--name", a.name)
|
||||
cmd.Env = append(os.Environ(), "XDG_CONFIG_HOME="+xdg)
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
t.Fatalf("accounts add %s: %v\n%s", a.name, err, out)
|
||||
}
|
||||
}
|
||||
|
||||
addr := freePort(t)
|
||||
stop := startProxy(t, bin, xdg, addr, upstream.URL)
|
||||
defer stop()
|
||||
|
||||
resp, err := http.Get("http://" + addr + "/api/version")
|
||||
if err != nil {
|
||||
t.Fatalf("GET: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode != 200 {
|
||||
t.Fatalf("status = %d, body = %s (want 200 after failover)", resp.StatusCode, body)
|
||||
}
|
||||
if !strings.Contains(string(body), `"ok":true`) {
|
||||
t.Errorf("body = %s, want good response", body)
|
||||
}
|
||||
}
|
||||
7
internal/cli/now.go
Normal file
7
internal/cli/now.go
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
package cli
|
||||
|
||||
import "time"
|
||||
|
||||
// nowFn returns the current time. It is an indirection so tests can stub it
|
||||
// via package-internal reassignment (see accounts_test.go).
|
||||
var nowFn = func() time.Time { return time.Now().UTC() }
|
||||
59
internal/cli/root.go
Normal file
59
internal/cli/root.go
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
// Package cli implements the ollama-proxy command-line interface: account
|
||||
// management subcommands and the `serve` subcommand that starts the proxy.
|
||||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
)
|
||||
|
||||
// Run dispatches to a subcommand based on args[1]. It returns the process exit
|
||||
// code, or an error that the caller should print before exiting 1.
|
||||
func Run(args []string) int {
|
||||
if len(args) < 2 {
|
||||
printRootUsage()
|
||||
return 1
|
||||
}
|
||||
switch args[1] {
|
||||
case "serve":
|
||||
return RunServe(args[2:])
|
||||
case "accounts":
|
||||
return RunAccounts(args[2:])
|
||||
case "version":
|
||||
fmt.Println("ollama-proxy", Version)
|
||||
return 0
|
||||
case "-h", "--help", "help":
|
||||
printRootUsage()
|
||||
return 0
|
||||
default:
|
||||
fmt.Fprintf(os.Stderr, "unknown subcommand: %s\n\n", args[1])
|
||||
printRootUsage()
|
||||
return 2
|
||||
}
|
||||
}
|
||||
|
||||
// Version is overwritten at build time via -ldflags "-X .../cli.Version=...".
|
||||
var Version = "dev"
|
||||
|
||||
func printRootUsage() {
|
||||
fmt.Print(`ollama-proxy — multi-account Ollama Cloud reverse proxy
|
||||
|
||||
Usage:
|
||||
ollama-proxy serve [flags] Start the proxy server
|
||||
ollama-proxy accounts <subcommand> Manage Ollama Cloud API keys
|
||||
ollama-proxy version Print version
|
||||
|
||||
Accounts subcommands:
|
||||
accounts add [API_KEY] [--name <alias>] Add an account (prompts for key if not given)
|
||||
accounts list List configured accounts
|
||||
accounts remove <id|name> Remove an account
|
||||
accounts set-base-url <url> Set the default upstream URL
|
||||
|
||||
Environment variables (serve):
|
||||
OLLAMA_PROXY_ADDR listen address (default 127.0.0.1:11435)
|
||||
OLLAMA_PROXY_BASE_URL upstream root (default https://ollama.com)
|
||||
OLLAMA_PROXY_COOLDOWN 429 cooldown window (default 60s)
|
||||
OLLAMA_PROXY_RETRIES max failover attempts (default 3)
|
||||
OLLAMA_PROXY_LOG_LEVEL debug|info|warn|error (default info)
|
||||
`)
|
||||
}
|
||||
109
internal/cli/serve.go
Normal file
109
internal/cli/serve.go
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/Atte149/ollama-proxy/internal/config"
|
||||
"github.com/Atte149/ollama-proxy/internal/log"
|
||||
"github.com/Atte149/ollama-proxy/internal/proxy"
|
||||
)
|
||||
|
||||
// RunServe starts the proxy HTTP server.
|
||||
func RunServe(args []string) int {
|
||||
fs := flag.NewFlagSet("serve", flag.ContinueOnError)
|
||||
addr := fs.String("addr", "", "listen address (default 127.0.0.1:11435)")
|
||||
baseURL := fs.String("base-url", "", "upstream root (default https://ollama.com)")
|
||||
cooldown := fs.Duration("cooldown", 0, "per-account 429 cooldown window")
|
||||
retries := fs.Int("retries", 0, "max failover attempts")
|
||||
logLevel := fs.String("log-level", "", "debug|info|warn|error")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
return 2
|
||||
}
|
||||
|
||||
cfg := config.DefaultServerConfig()
|
||||
if *addr != "" {
|
||||
cfg.Addr = *addr
|
||||
}
|
||||
if *baseURL != "" {
|
||||
cfg.BaseURL = *baseURL
|
||||
}
|
||||
if *cooldown > 0 {
|
||||
cfg.Cooldown = *cooldown
|
||||
}
|
||||
if *retries > 0 {
|
||||
cfg.Retries = *retries
|
||||
}
|
||||
if *logLevel != "" {
|
||||
cfg.LogLevel = *logLevel
|
||||
}
|
||||
cfg.ApplyEnv()
|
||||
if err := cfg.Validate(); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "config error:", err)
|
||||
return 2
|
||||
}
|
||||
|
||||
logger := log.New(cfg.LogLevel)
|
||||
|
||||
af, err := config.LoadAccounts()
|
||||
if err != nil {
|
||||
logger.Error("load accounts failed", "err", err)
|
||||
return 1
|
||||
}
|
||||
if len(af.Accounts) == 0 {
|
||||
logger.Error("no accounts configured; run `ollama-proxy accounts add` first")
|
||||
return 1
|
||||
}
|
||||
// Per-file BaseURL override wins over flag/env when set.
|
||||
if af.BaseURL != "" {
|
||||
cfg.BaseURL = af.BaseURL
|
||||
}
|
||||
|
||||
balancer := proxy.NewBalancer(af.Accounts, cfg.Cooldown)
|
||||
handler := proxy.NewHandler(balancer, cfg.BaseURL, cfg.Retries, logger)
|
||||
|
||||
srv := &http.Server{
|
||||
Addr: cfg.Addr,
|
||||
Handler: handler,
|
||||
// No ReadTimeout/WriteTimeout: streaming chat may be idle for long
|
||||
// periods between tokens. The http.Server's default idle timeout (60s)
|
||||
// applies between reads; if that becomes a problem we'll add a per-read
|
||||
// deadline via a wrapped Transport, not a global timeout.
|
||||
}
|
||||
|
||||
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
go func() {
|
||||
logger.Info("ollama-proxy starting",
|
||||
"addr", cfg.Addr, "upstream", cfg.BaseURL,
|
||||
"accounts", balancer.Len(), "cooldown", cfg.Cooldown.String(),
|
||||
"retries", cfg.Retries, "log_level", cfg.LogLevel)
|
||||
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
logger.Error("server error", "err", err)
|
||||
stop()
|
||||
}
|
||||
}()
|
||||
|
||||
<-ctx.Done()
|
||||
logger.Info("shutdown signal received, draining…")
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
if err := srv.Shutdown(shutdownCtx); err != nil {
|
||||
logger.Error("graceful shutdown failed", "err", err)
|
||||
return 1
|
||||
}
|
||||
logger.Info("stopped")
|
||||
return 0
|
||||
}
|
||||
|
||||
// keep slog referenced even if logger.go ever drops it
|
||||
var _ = slog.LevelInfo
|
||||
Loading…
Add table
Add a link
Reference in a new issue