- Account.Provider field (ollama-cloud | opencode-go), backward compatible - Model-based routing: common models served by combined pool, unique models routed to their provider only - /go/v1/* path forces OpenCode Go provider (prefix stripped upstream) - Merged /v1/models endpoint returns union of both catalogs (44 models) - Failover: 429/402 → cooldown + failover; 5xx → retry without cooldown - CLI: accounts add --provider flag, list shows provider column - Body buffering: request body buffered (8 MiB cap) for failover replay - opencode integration: unified provider 'oc' with all merged models - 64 tests pass (unit + integration) - Verified: glm-5 → ollama, mimo-v2.5 → go, gpt-oss:20b → ollama
256 lines
6.9 KiB
Go
256 lines
6.9 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"
|
|
)
|
|
|
|
// ProviderType identifies which upstream service an account belongs to.
|
|
type ProviderType string
|
|
|
|
const (
|
|
// ProviderOllamaCloud is the Ollama Cloud API at https://ollama.com.
|
|
ProviderOllamaCloud ProviderType = "ollama-cloud"
|
|
// ProviderOpenCodeGo is the OpenCode Go API at https://opencode.ai/zen/go/v1.
|
|
ProviderOpenCodeGo ProviderType = "opencode-go"
|
|
)
|
|
|
|
// DefaultProviderBaseURL returns the canonical upstream root for a provider.
|
|
func DefaultProviderBaseURL(p ProviderType) string {
|
|
switch p {
|
|
case ProviderOpenCodeGo:
|
|
return "https://opencode.ai/zen/go"
|
|
default:
|
|
return "https://ollama.com"
|
|
}
|
|
}
|
|
|
|
// ValidProvider reports whether p is a recognised provider type.
|
|
func ValidProvider(p ProviderType) bool {
|
|
switch p {
|
|
case ProviderOllamaCloud, ProviderOpenCodeGo:
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
// Account is a single upstream API key with an optional alias.
|
|
type Account struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Provider ProviderType `json:"provider,omitempty"` // empty = ollama-cloud (backward compat)
|
|
APIKey string `json:"api_key"`
|
|
BaseURL string `json:"base_url,omitempty"` // overrides the provider default; 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)
|
|
}
|
|
// Backward compat: accounts without an explicit provider default to
|
|
// ollama-cloud (the original behaviour before multi-provider support).
|
|
for i := range af.Accounts {
|
|
if af.Accounts[i].Provider == "" {
|
|
af.Accounts[i].Provider = ProviderOllamaCloud
|
|
}
|
|
}
|
|
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
|
|
}
|