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
218 lines
5.7 KiB
Go
218 lines
5.7 KiB
Go
// Package config holds runtime configuration for ollama-proxy: the list of
|
|
// Ollama Cloud accounts and the server runtime options.
|
|
package config
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"time"
|
|
)
|
|
|
|
// Account is a single Ollama Cloud API key with an optional alias.
|
|
type Account struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
APIKey string `json:"api_key"`
|
|
BaseURL string `json:"base_url,omitempty"` // overrides ServerConfig.BaseURL; empty = use default
|
|
Created time.Time `json:"created"`
|
|
}
|
|
|
|
// AccountsFile is the on-disk JSON structure.
|
|
type AccountsFile struct {
|
|
BaseURL string `json:"base_url,omitempty"` // default upstream URL
|
|
Accounts []Account `json:"accounts"`
|
|
}
|
|
|
|
// AccountsPath returns the canonical accounts.json path under XDG_CONFIG_HOME
|
|
// (or ~/.config when XDG_CONFIG_HOME is unset). The parent directory must exist.
|
|
func AccountsPath() (string, error) {
|
|
dir := os.Getenv("XDG_CONFIG_HOME")
|
|
if dir == "" {
|
|
home, err := os.UserHomeDir()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
dir = filepath.Join(home, ".config")
|
|
}
|
|
return filepath.Join(dir, "ollama-proxy", "accounts.json"), nil
|
|
}
|
|
|
|
// EnsureAccountsDir creates ~/.config/ollama-proxy with 0700 perms if missing.
|
|
func EnsureAccountsDir() error {
|
|
p, err := AccountsPath()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := os.MkdirAll(filepath.Dir(p), 0o700); err != nil {
|
|
return fmt.Errorf("create config dir: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// LoadAccounts reads accounts.json from the default path. Returns an empty
|
|
// AccountsFile (not an error) when the file does not exist.
|
|
func LoadAccounts() (*AccountsFile, error) {
|
|
p, err := AccountsPath()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
data, err := os.ReadFile(p)
|
|
if err != nil {
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
return &AccountsFile{}, nil
|
|
}
|
|
return nil, fmt.Errorf("read accounts: %w", err)
|
|
}
|
|
var af AccountsFile
|
|
if err := json.Unmarshal(data, &af); err != nil {
|
|
return nil, fmt.Errorf("parse accounts: %w", err)
|
|
}
|
|
return &af, nil
|
|
}
|
|
|
|
// Save writes accounts.json atomically with 0600 permissions. The temp file is
|
|
// written in the same directory and renamed, so a crash mid-write never leaves
|
|
// a truncated file. The parent directory must already exist (see EnsureAccountsDir).
|
|
func (af *AccountsFile) Save() error {
|
|
p, err := AccountsPath()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if af.Accounts == nil {
|
|
af.Accounts = []Account{}
|
|
}
|
|
data, err := json.MarshalIndent(af, "", " ")
|
|
if err != nil {
|
|
return fmt.Errorf("marshal accounts: %w", err)
|
|
}
|
|
dir := filepath.Dir(p)
|
|
tmp, err := os.CreateTemp(dir, ".accounts.json.*")
|
|
if err != nil {
|
|
return fmt.Errorf("create temp: %w", err)
|
|
}
|
|
tmpName := tmp.Name()
|
|
defer func() {
|
|
if err != nil {
|
|
_ = os.Remove(tmpName)
|
|
}
|
|
}()
|
|
if err := tmp.Chmod(0o600); err != nil {
|
|
tmp.Close()
|
|
return fmt.Errorf("chmod temp: %w", err)
|
|
}
|
|
if _, err := tmp.Write(data); err != nil {
|
|
tmp.Close()
|
|
return fmt.Errorf("write temp: %w", err)
|
|
}
|
|
if err := tmp.Close(); err != nil {
|
|
return fmt.Errorf("close temp: %w", err)
|
|
}
|
|
if err := os.Rename(tmpName, p); err != nil {
|
|
return fmt.Errorf("rename temp: %w", err)
|
|
}
|
|
if err := os.Chmod(p, 0o600); err != nil {
|
|
return fmt.Errorf("chmod final: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// NewID returns a random 16-byte hex string suitable as a stable account ID.
|
|
func NewID() string {
|
|
b := make([]byte, 16)
|
|
if _, err := rand.Read(b); err != nil {
|
|
panic("crypto/rand failed: " + err.Error())
|
|
}
|
|
return hex.EncodeToString(b)
|
|
}
|
|
|
|
// shortID returns the first 8 chars of an ID, for display only.
|
|
func shortID(id string) string {
|
|
if len(id) < 8 {
|
|
return id
|
|
}
|
|
return id[:8]
|
|
}
|
|
|
|
// maskKey renders an API key as "****ABCD" (last 4 chars) for safe display.
|
|
func maskKey(key string) string {
|
|
if len(key) <= 4 {
|
|
return "****"
|
|
}
|
|
return "****" + key[len(key)-4:]
|
|
}
|
|
|
|
// FindByName returns the first account matching name (case-sensitive). Returns
|
|
// nil when no account matches.
|
|
func (af *AccountsFile) FindByName(name string) *Account {
|
|
for i := range af.Accounts {
|
|
if af.Accounts[i].Name == name {
|
|
return &af.Accounts[i]
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// FindByID returns the account with the given ID prefix (matches short ID too).
|
|
func (af *AccountsFile) FindByID(idPrefix string) *Account {
|
|
for i := range af.Accounts {
|
|
if af.Accounts[i].ID == idPrefix || hasPrefix(af.Accounts[i].ID, idPrefix) {
|
|
return &af.Accounts[i]
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func hasPrefix(s, prefix string) bool {
|
|
if len(prefix) > len(s) {
|
|
return false
|
|
}
|
|
return s[:len(prefix)] == prefix
|
|
}
|
|
|
|
// Find returns the account matching the given identifier, which may be either
|
|
// a name or an ID/ID-prefix. Name match takes precedence.
|
|
func (af *AccountsFile) Find(ident string) *Account {
|
|
if a := af.FindByName(ident); a != nil {
|
|
return a
|
|
}
|
|
return af.FindByID(ident)
|
|
}
|
|
|
|
// Remove deletes the account with the given ID or name. Returns ErrNotFound
|
|
// when no account matched.
|
|
func (af *AccountsFile) Remove(ident string) error {
|
|
idx := -1
|
|
for i := range af.Accounts {
|
|
if af.Accounts[i].Name == ident || af.Accounts[i].ID == ident || hasPrefix(af.Accounts[i].ID, ident) {
|
|
idx = i
|
|
break
|
|
}
|
|
}
|
|
if idx < 0 {
|
|
return ErrNotFound
|
|
}
|
|
af.Accounts = append(af.Accounts[:idx], af.Accounts[idx+1:]...)
|
|
return nil
|
|
}
|
|
|
|
// ErrNotFound is returned when an account lookup or removal misses.
|
|
var ErrNotFound = errors.New("account not found")
|
|
|
|
// ValidateName returns an error when name contains characters outside
|
|
// [A-Za-z0-9_-]. Names must be safe for CLI display and log output.
|
|
func ValidateName(name string) error {
|
|
ok, err := regexp.MatchString(`^[A-Za-z0-9_-]+$`, name)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !ok {
|
|
return errors.New("name must contain only letters, digits, '_' or '-'")
|
|
}
|
|
return nil
|
|
}
|