Files
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 12:33:42 +08:00

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)
}