a06f331eb8
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
CI / build-onnx (push) Failing after 6m43s
init-smoke / dry-run (push) Failing after 15m57s
security / govulncheck (push) Has been cancelled
security / trivy-fs (push) Has been cancelled
CI / test (1.26, ubuntu-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
CI / test (1.26, macos-latest) (push) Has been cancelled
CI / build-windows (push) Has been cancelled
CI / lint (push) Has been cancelled
install-script / powershell-syntax (push) Has been cancelled
install-script / install (macos-14) (push) Has been cancelled
install-script / install (ubuntu-latest) (push) Has been cancelled
292 lines
9.2 KiB
Go
292 lines
9.2 KiB
Go
// Package registry manages the user's custom OpenAI-compatible LLM
|
|
// providers — the on-disk providers.json that `gortex provider
|
|
// add/list/show/remove` edits, and the merge that hands those entries
|
|
// to llm.Config so they dispatch like any built-in provider.
|
|
//
|
|
// Two locations are consulted, mirroring the safety posture of the rest
|
|
// of the toolchain:
|
|
//
|
|
// - the global file at <ConfigDir>/providers.json (always trusted);
|
|
// - a repo-local .gortex/providers.json, loaded ONLY when
|
|
// GORTEX_ALLOW_LOCAL_PROVIDERS=1, so a cloned repo cannot silently
|
|
// point gortex's LLM calls at an attacker-controlled endpoint.
|
|
//
|
|
// Entries are validated on load: the base_url must be http/https and
|
|
// the name must not shadow a built-in provider. Invalid entries are
|
|
// skipped with a warning rather than failing the whole load, so one bad
|
|
// row never disables the agent.
|
|
package registry
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
|
|
"github.com/zzet/gortex/internal/llm"
|
|
"github.com/zzet/gortex/internal/platform"
|
|
)
|
|
|
|
// LocalOptInEnv gates loading the repo-local providers.json. Loading an
|
|
// endpoint definition from a cloned repo is a supply-chain risk, so it
|
|
// is off unless the user explicitly opts in.
|
|
const LocalOptInEnv = "GORTEX_ALLOW_LOCAL_PROVIDERS"
|
|
|
|
// GlobalPath is the trusted, user-global providers.json next to the
|
|
// rest of the gortex config.
|
|
func GlobalPath() string {
|
|
return filepath.Join(platform.ConfigDir(), "providers.json")
|
|
}
|
|
|
|
// LocalPath is the opt-in, repo-local providers.json relative to the
|
|
// current working directory.
|
|
func LocalPath() string {
|
|
return filepath.Join(".gortex", "providers.json")
|
|
}
|
|
|
|
// Load reads the global providers.json (and the repo-local one when
|
|
// GORTEX_ALLOW_LOCAL_PROVIDERS=1), validates each entry, and returns
|
|
// the merged map keyed by provider name. A repo-local entry overrides a
|
|
// global one of the same name. warnings carries human-readable reasons
|
|
// for any skipped entry; a missing file is not an error.
|
|
func Load() (providers map[string]llm.CustomProvider, warnings []string, err error) {
|
|
providers = map[string]llm.CustomProvider{}
|
|
|
|
g, gw, err := loadFile(GlobalPath())
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
warnings = append(warnings, gw...)
|
|
for name, cp := range g {
|
|
providers[name] = cp
|
|
}
|
|
|
|
if optedInLocal() {
|
|
l, lw, err := loadFile(LocalPath())
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
warnings = append(warnings, lw...)
|
|
for name, cp := range l {
|
|
providers[name] = cp // repo-local overrides global
|
|
}
|
|
}
|
|
return providers, warnings, nil
|
|
}
|
|
|
|
// Augment merges the registry's custom providers into cfg.Custom. An
|
|
// inline `llm.custom:` entry from the config file wins over a
|
|
// providers.json entry of the same name. The returned warnings describe
|
|
// any skipped on-disk entries.
|
|
func Augment(cfg llm.Config) (llm.Config, []string) {
|
|
loaded, warnings, err := Load()
|
|
if err != nil {
|
|
return cfg, []string{fmt.Sprintf("custom providers: %v", err)}
|
|
}
|
|
if len(loaded) == 0 {
|
|
return cfg, warnings
|
|
}
|
|
merged := make(map[string]llm.CustomProvider, len(loaded)+len(cfg.Custom))
|
|
for name, cp := range loaded {
|
|
merged[name] = cp
|
|
}
|
|
for name, cp := range cfg.Custom { // inline config wins
|
|
merged[name] = cp
|
|
}
|
|
cfg.Custom = merged
|
|
return cfg, warnings
|
|
}
|
|
|
|
// List returns the entries from the global providers.json, sorted by
|
|
// name. The repo-local file is intentionally excluded — `provider list`
|
|
// reports the trusted set the user manages.
|
|
func List() ([]Entry, error) {
|
|
m, _, err := loadFile(GlobalPath())
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out := make([]Entry, 0, len(m))
|
|
for name, cp := range m {
|
|
out = append(out, Entry{Name: name, Provider: cp})
|
|
}
|
|
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
|
|
return out, nil
|
|
}
|
|
|
|
// Get returns one entry from the global providers.json.
|
|
func Get(name string) (llm.CustomProvider, bool, error) {
|
|
m, _, err := loadFile(GlobalPath())
|
|
if err != nil {
|
|
return llm.CustomProvider{}, false, err
|
|
}
|
|
cp, ok := m[strings.TrimSpace(name)]
|
|
return cp, ok, nil
|
|
}
|
|
|
|
// Add validates and writes a provider into the global providers.json,
|
|
// creating the file (and its directory) if needed. An existing entry of
|
|
// the same name is replaced.
|
|
func Add(name string, cp llm.CustomProvider) error {
|
|
name = strings.TrimSpace(name)
|
|
if err := validate(name, cp); err != nil {
|
|
return err
|
|
}
|
|
m, _, err := loadFileRaw(GlobalPath())
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if m == nil {
|
|
m = map[string]llm.CustomProvider{}
|
|
}
|
|
m[name] = cp
|
|
return save(GlobalPath(), m)
|
|
}
|
|
|
|
// Remove deletes a provider from the global providers.json. It is not
|
|
// an error to remove an absent entry; removed reports whether one was
|
|
// actually present.
|
|
func Remove(name string) (removed bool, err error) {
|
|
name = strings.TrimSpace(name)
|
|
m, _, err := loadFileRaw(GlobalPath())
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
if _, ok := m[name]; !ok {
|
|
return false, nil
|
|
}
|
|
delete(m, name)
|
|
return true, save(GlobalPath(), m)
|
|
}
|
|
|
|
// EstimateCost returns the USD cost of a call against a custom
|
|
// provider's optional pricing (USD per 1M tokens). Zero pricing yields
|
|
// zero — pricing is purely informational.
|
|
func EstimateCost(cp llm.CustomProvider, inputTokens, outputTokens int64) float64 {
|
|
return (float64(inputTokens)*cp.Pricing.Input + float64(outputTokens)*cp.Pricing.Output) / 1_000_000.0
|
|
}
|
|
|
|
// Entry pairs a provider name with its definition for List.
|
|
type Entry struct {
|
|
Name string
|
|
Provider llm.CustomProvider
|
|
}
|
|
|
|
func optedInLocal() bool {
|
|
v := strings.TrimSpace(os.Getenv(LocalOptInEnv))
|
|
return v == "1" || strings.EqualFold(v, "true")
|
|
}
|
|
|
|
// loadFile reads and validates a providers.json, skipping invalid
|
|
// entries with a warning. A missing file yields an empty map.
|
|
func loadFile(path string) (map[string]llm.CustomProvider, []string, error) {
|
|
raw, _, err := loadFileRaw(path)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
out := make(map[string]llm.CustomProvider, len(raw))
|
|
var warnings []string
|
|
for name, cp := range raw {
|
|
if err := validate(name, cp); err != nil {
|
|
warnings = append(warnings, fmt.Sprintf("%s: skipped provider %q: %v", path, name, err))
|
|
continue
|
|
}
|
|
if w := plaintextWarning(name, cp); w != "" {
|
|
warnings = append(warnings, w)
|
|
}
|
|
out[name] = cp
|
|
}
|
|
return out, warnings, nil
|
|
}
|
|
|
|
// loadFileRaw reads a providers.json without validation (used by the
|
|
// mutating CLI paths, which validate explicitly). A missing file yields
|
|
// a nil map and no error.
|
|
func loadFileRaw(path string) (map[string]llm.CustomProvider, bool, error) {
|
|
data, err := os.ReadFile(path)
|
|
if os.IsNotExist(err) {
|
|
return nil, false, nil
|
|
}
|
|
if err != nil {
|
|
return nil, false, fmt.Errorf("read %s: %w", path, err)
|
|
}
|
|
if len(strings.TrimSpace(string(data))) == 0 {
|
|
return nil, true, nil
|
|
}
|
|
var m map[string]llm.CustomProvider
|
|
if err := json.Unmarshal(data, &m); err != nil {
|
|
return nil, true, fmt.Errorf("parse %s: %w", path, err)
|
|
}
|
|
return m, true, nil
|
|
}
|
|
|
|
func save(path string, m map[string]llm.CustomProvider) error {
|
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
|
return fmt.Errorf("create config dir: %w", err)
|
|
}
|
|
data, err := json.MarshalIndent(m, "", " ")
|
|
if err != nil {
|
|
return fmt.Errorf("encode providers: %w", err)
|
|
}
|
|
data = append(data, '\n')
|
|
// Write atomically: temp + rename, so a crash mid-write never
|
|
// leaves a truncated providers.json.
|
|
tmp := path + ".tmp"
|
|
if err := os.WriteFile(tmp, data, 0o600); err != nil {
|
|
return fmt.Errorf("write %s: %w", tmp, err)
|
|
}
|
|
if err := os.Rename(tmp, path); err != nil {
|
|
_ = os.Remove(tmp)
|
|
return fmt.Errorf("rename into %s: %w", path, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// validate enforces the invariants every custom provider must satisfy:
|
|
// a non-empty name that does not shadow a built-in, an http/https
|
|
// base_url, a model, and a recognised schema_mode.
|
|
func validate(name string, cp llm.CustomProvider) error {
|
|
if strings.TrimSpace(name) == "" {
|
|
return fmt.Errorf("name is empty")
|
|
}
|
|
if llm.IsBuiltinProvider(name) {
|
|
return fmt.Errorf("name shadows the built-in provider %q", strings.ToLower(name))
|
|
}
|
|
base := strings.TrimSpace(cp.BaseURL)
|
|
if base == "" {
|
|
return fmt.Errorf("base_url is empty")
|
|
}
|
|
if !strings.HasPrefix(base, "http://") && !strings.HasPrefix(base, "https://") {
|
|
return fmt.Errorf("base_url must be http(s), got %q", base)
|
|
}
|
|
if strings.TrimSpace(cp.Model) == "" {
|
|
return fmt.Errorf("model is empty")
|
|
}
|
|
switch strings.ToLower(strings.TrimSpace(cp.SchemaMode)) {
|
|
case "", "json_schema", "json_object", "prompt", "prompt_only", "none":
|
|
default:
|
|
return fmt.Errorf("unknown schema_mode %q (want json_schema|json_object|prompt)", cp.SchemaMode)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// plaintextWarning flags a cleartext http endpoint pointed at a
|
|
// non-loopback host — allowed, but worth a heads-up since the key would
|
|
// travel unencrypted.
|
|
func plaintextWarning(name string, cp llm.CustomProvider) string {
|
|
base := strings.TrimSpace(cp.BaseURL)
|
|
if !strings.HasPrefix(base, "http://") {
|
|
return ""
|
|
}
|
|
rest := strings.TrimPrefix(base, "http://")
|
|
host := rest
|
|
if i := strings.IndexAny(rest, ":/"); i >= 0 {
|
|
host = rest[:i]
|
|
}
|
|
if host == "localhost" || host == "127.0.0.1" || host == "::1" {
|
|
return ""
|
|
}
|
|
return fmt.Sprintf("custom provider %q uses cleartext http to non-loopback host %q — the API key travels unencrypted", name, host)
|
|
}
|