// Package providers manages the set of configured LLM providers and which one // is active (globally or per session). API keys never leave this package. package providers import ( "context" "encoding/json" "errors" "fmt" "io" "net/http" "sort" "strings" "sync" "time" "github.com/crypto/byteclaw/internal/config" "github.com/crypto/byteclaw/internal/llm" "github.com/crypto/byteclaw/internal/store" ) var ErrUnknown = errors.New("unknown provider") var ErrNotConfigured = errors.New("provider has no API key configured") const settingKey = "active_provider" type entry struct { name, baseURL, model, apiKey string keyless bool } // Info is the public view of a provider (no secrets). type Info struct { Name string `json:"name"` BaseURL string `json:"base_url"` DefaultModel string `json:"default_model"` Configured bool `json:"configured"` Active bool `json:"active"` } type Active struct { Provider string `json:"provider"` Model string `json:"model"` } type Manager struct { mu sync.RWMutex entries map[string]*entry active Active store *store.Store timeout time.Duration temperature float64 HTTP *http.Client // New builds a provider; replaceable in tests. New func(baseURL, apiKey, model string) llm.Provider } // New builds the manager from config: every preset (key from llm.providers. // or the preset's env var) plus the provider configured in llm.*. func New(cfg config.LLM, getenv func(string) string, st *store.Store) *Manager { m := &Manager{entries: map[string]*entry{}, store: st, timeout: time.Duration(cfg.TimeoutSec) * time.Second, temperature: cfg.Temperature, HTTP: &http.Client{Timeout: 15 * time.Second}} m.New = func(baseURL, apiKey, model string) llm.Provider { return llm.NewOpenAI(baseURL, apiKey, model, m.temperature, m.timeout) } for name, p := range config.Presets { e := &entry{name: name, baseURL: p.BaseURL, model: p.DefaultModel, keyless: p.KeyEnv == ""} if p.KeyEnv != "" { e.apiKey = getenv(p.KeyEnv) } m.entries[name] = e } for name, o := range cfg.Providers { name = strings.ToLower(name) e, ok := m.entries[name] if !ok { e = &entry{name: name} m.entries[name] = e } if o.BaseURL != "" { e.baseURL = o.BaseURL } if o.APIKey != "" { e.apiKey = o.APIKey } if o.Model != "" { e.model = o.Model } } // The primary llm.* settings win for the configured provider. e, ok := m.entries[cfg.Provider] if !ok { e = &entry{name: cfg.Provider, keyless: true} m.entries[cfg.Provider] = e } e.baseURL, e.model = cfg.BaseURL, cfg.Model if cfg.APIKey != "" { e.apiKey = cfg.APIKey } m.active = Active{Provider: cfg.Provider, Model: cfg.Model} if st != nil { if raw, err := st.GetSetting(context.Background(), settingKey); err == nil { var a Active if json.Unmarshal([]byte(raw), &a) == nil && m.usable(a.Provider) == nil { m.active = a } } } return m } func (m *Manager) usable(name string) error { e, ok := m.entries[name] if !ok || e.baseURL == "" { return fmt.Errorf("%w: %q", ErrUnknown, name) } if e.apiKey == "" && !e.keyless { return fmt.Errorf("%w: %q", ErrNotConfigured, name) } return nil } func (m *Manager) List() []Info { m.mu.RLock() defer m.mu.RUnlock() out := make([]Info, 0, len(m.entries)) for _, e := range m.entries { if e.baseURL == "" { continue } out = append(out, Info{Name: e.name, BaseURL: e.baseURL, DefaultModel: e.model, Configured: m.usable(e.name) == nil, Active: e.name == m.active.Provider}) } sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) return out } func (m *Manager) Active() Active { m.mu.RLock() defer m.mu.RUnlock() return m.active } func (m *Manager) BaseURL(name string) string { m.mu.RLock() defer m.mu.RUnlock() if e, ok := m.entries[name]; ok { return e.baseURL } return "" } // Validate checks provider/model and fills the default model. func (m *Manager) Validate(name, model string) (Active, error) { m.mu.RLock() defer m.mu.RUnlock() name = strings.ToLower(strings.TrimSpace(name)) if err := m.usable(name); err != nil { return Active{}, err } if model = strings.TrimSpace(model); model == "" { model = m.entries[name].model } if model == "" { return Active{}, errors.New("model required") } return Active{Provider: name, Model: model}, nil } // SetActive switches the server-wide default and persists it. func (m *Manager) SetActive(ctx context.Context, name, model string) (Active, error) { a, err := m.Validate(name, model) if err != nil { return a, err } m.mu.Lock() m.active = a m.mu.Unlock() if m.store != nil { b, _ := json.Marshal(a) if err := m.store.SetSetting(ctx, settingKey, string(b)); err != nil { return a, err } } return a, nil } // Build returns a provider client for name/model. func (m *Manager) Build(name, model string) (llm.Provider, error) { a, err := m.Validate(name, model) if err != nil { return nil, err } m.mu.RLock() e := *m.entries[a.Provider] m.mu.RUnlock() return m.New(e.baseURL, e.apiKey, a.Model), nil } // ForSession resolves the provider for a session (session override, else active). func (m *Manager) ForSession(ctx context.Context, sessionID string) (llm.Provider, Active, error) { a := m.Active() if m.store != nil && sessionID != "" { if ss, err := m.store.GetSession(ctx, sessionID); err == nil && ss.Provider != "" { a = Active{Provider: ss.Provider, Model: ss.Model} } } p, err := m.Build(a.Provider, a.Model) return p, a, err } // Models lists model ids from the provider's OpenAI-compatible GET /models (key added server-side). func (m *Manager) Models(ctx context.Context, name string) ([]string, error) { m.mu.RLock() e, ok := m.entries[strings.ToLower(name)] var ec entry if ok { ec = *e } m.mu.RUnlock() if !ok || ec.baseURL == "" { return nil, fmt.Errorf("%w: %q", ErrUnknown, name) } req, err := http.NewRequestWithContext(ctx, http.MethodGet, strings.TrimRight(ec.baseURL, "/")+"/models", nil) if err != nil { return nil, err } if ec.apiKey != "" { req.Header.Set("Authorization", "Bearer "+ec.apiKey) } resp, err := m.HTTP.Do(req) if err != nil { return nil, err } defer resp.Body.Close() if resp.StatusCode/100 != 2 { return nil, fmt.Errorf("models endpoint returned HTTP %d", resp.StatusCode) } var body struct { Data []struct { ID string `json:"id"` } `json:"data"` } if err := json.NewDecoder(io.LimitReader(resp.Body, 4<<20)).Decode(&body); err != nil { return nil, fmt.Errorf("decode models: %w", err) } ids := make([]string, 0, len(body.Data)) for _, d := range body.Data { if d.ID != "" { ids = append(ids, d.ID) } } sort.Strings(ids) return ids, nil }