feat: provider manager, cancellation, session summaries (store+agent)
This commit is contained in:
parent
406a523d54
commit
f1b2093b22
10 changed files with 883 additions and 37 deletions
255
internal/providers/providers.go
Normal file
255
internal/providers/providers.go
Normal file
|
|
@ -0,0 +1,255 @@
|
|||
// 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.<name>
|
||||
// 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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue