CI / ci (push) Successful in 2m0s
Co-authored-by: dev_agent <dev_agent@cubecraftlabs.com>
479 lines
16 KiB
Go
479 lines
16 KiB
Go
// Package aisettings reads and changes the household's AI settings — the
|
|
// ai_settings row seeded by household.SeedAISettings — and builds the
|
|
// provider and harness.Config they describe. The API key is sealed with the
|
|
// secret box and never leaves the package except to the provider.
|
|
package aisettings
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
"math"
|
|
"net/url"
|
|
"strings"
|
|
"unicode/utf8"
|
|
|
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness"
|
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm"
|
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm/presets"
|
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
|
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
|
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/secretbox"
|
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/secureurl"
|
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
|
)
|
|
|
|
const (
|
|
// MaxSystemPromptChars is the longest system prompt accepted, in
|
|
// characters.
|
|
MaxSystemPromptChars = 8000
|
|
// MinKeyLength is the shortest API key accepted, so its last four
|
|
// characters never give away most of it.
|
|
MinKeyLength = 8
|
|
)
|
|
|
|
var (
|
|
// ErrNotConfigured is returned when the stored AI settings cannot
|
|
// build a provider, e.g. there is no API key yet. It is joined with
|
|
// the llm configuration error that says why.
|
|
ErrNotConfigured = errors.New("aisettings: AI provider is not configured")
|
|
// ErrProviderFailed is returned when a call to the provider fails. It
|
|
// is joined with the provider's error, so the llm typed error it
|
|
// matches says why.
|
|
ErrProviderFailed = errors.New("aisettings: AI provider call failed")
|
|
// ErrChanged is returned when the AI settings changed while
|
|
// their key was being verified, so the result applies to old settings.
|
|
ErrChanged = errors.New("aisettings: AI settings changed during verification")
|
|
)
|
|
|
|
// Settings is the household's AI configuration as the API shows it. The
|
|
// API key is never part of it: only its last four characters.
|
|
type Settings struct {
|
|
Provider string `json:"provider"`
|
|
BaseURL string `json:"base_url"`
|
|
Model string `json:"model"`
|
|
Temperature float64 `json:"temperature"`
|
|
// SystemPrompt is the admin persona, nil when none is stored.
|
|
SystemPrompt *string `json:"system_prompt"`
|
|
// APIKeyLast4 is nil when no key is stored.
|
|
APIKeyLast4 *string `json:"api_key_last4"`
|
|
// KeyVerifiedAt is when the stored key was last verified with these
|
|
// provider settings, nil when it has not been.
|
|
KeyVerifiedAt *string `json:"key_verified_at"`
|
|
UpdatedAt string `json:"updated_at"`
|
|
}
|
|
|
|
// Patch changes the settings. A nil field is left as it is.
|
|
type Patch struct {
|
|
Provider *string `json:"provider"`
|
|
BaseURL *string `json:"base_url"`
|
|
Model *string `json:"model"`
|
|
Temperature *float64 `json:"temperature"`
|
|
SystemPrompt *string `json:"system_prompt"`
|
|
}
|
|
|
|
// Model is one model the provider offers.
|
|
type Model struct {
|
|
ID string `json:"id"`
|
|
DisplayName string `json:"display_name"`
|
|
}
|
|
|
|
// Problem is one invalid field, named by its JSON key.
|
|
type Problem struct {
|
|
Field string
|
|
Reason string
|
|
}
|
|
|
|
// ValidationError lists every problem with an Patch or API
|
|
// key, or with the settings it would leave. No problem quotes the key.
|
|
type ValidationError struct {
|
|
Problems []Problem
|
|
}
|
|
|
|
func (e *ValidationError) Error() string {
|
|
parts := make([]string, len(e.Problems))
|
|
for i, p := range e.Problems {
|
|
parts[i] = p.Field + ": " + p.Reason
|
|
}
|
|
return "invalid AI settings: " + strings.Join(parts, "; ")
|
|
}
|
|
|
|
// Service reads and changes the ai_settings row and builds the provider it
|
|
// names. API keys are sealed with its secret box and never leave it except
|
|
// to the provider.
|
|
type Service struct {
|
|
st *store.Store
|
|
box *secretbox.Box
|
|
publicURL string
|
|
// newProvider and newAccount are presets.FromSettings and
|
|
// presets.ForAccount; tests substitute a fake provider.
|
|
newProvider harness.NewProvider
|
|
newAccount func(llm.Settings, string) (presets.Account, error)
|
|
}
|
|
|
|
// New returns the service over st, sealing keys with box.
|
|
// publicURL is MEALQUEST_PUBLIC_URL, which some presets send.
|
|
func New(st *store.Store, box *secretbox.Box, publicURL string) (*Service, error) {
|
|
switch {
|
|
case st == nil:
|
|
return nil, errors.New("aisettings: nil store")
|
|
case box == nil:
|
|
return nil, errors.New("aisettings: nil secret box")
|
|
case publicURL == "":
|
|
return nil, errors.New("aisettings: empty public URL")
|
|
}
|
|
return &Service{st: st, box: box, publicURL: publicURL, newProvider: presets.FromSettings, newAccount: presets.ForAccount}, nil
|
|
}
|
|
|
|
// Get returns the settings.
|
|
func (s *Service) Get(ctx context.Context) (Settings, error) {
|
|
row, err := s.read(ctx, db.New(s.st.Reader))
|
|
if err != nil {
|
|
return Settings{}, err
|
|
}
|
|
return settingsFrom(row), nil
|
|
}
|
|
|
|
// Update applies patch and returns the settings it leaves. The provider
|
|
// and base URL cannot change while an API key is stored, so the key never
|
|
// reaches an endpoint it was not stored for. Every problem is reported in
|
|
// a *ValidationError and nothing changes.
|
|
func (s *Service) Update(ctx context.Context, patch Patch) (Settings, error) {
|
|
return s.change(ctx, func(row db.AiSetting) (db.AiSetting, error) {
|
|
return applyPatch(row, patch)
|
|
})
|
|
}
|
|
|
|
// SetAPIKey seals key and records its last four characters, clearing
|
|
// key_verified_at. An empty key removes the stored one. A preset without
|
|
// a fixed base URL takes a key only when its base URL is secure (see
|
|
// secureurl.Check).
|
|
func (s *Service) SetAPIKey(ctx context.Context, key string) (Settings, error) {
|
|
return s.change(ctx, func(row db.AiSetting) (db.AiSetting, error) {
|
|
row.ApiKeyCt, row.ApiKeyLast4, row.KeyVerifiedAt = nil, sql.NullString{}, sql.NullString{}
|
|
if key == "" {
|
|
return row, nil
|
|
}
|
|
p, _ := presets.Lookup(row.Provider)
|
|
var reason string
|
|
switch {
|
|
case p.Key == presets.KeyNone:
|
|
reason = row.Provider + " takes no API key"
|
|
case p.BaseURL == "" && !secureBaseURL(row.BaseUrl):
|
|
reason = "is sent only over https://, or http:// to localhost: set such a base_url first"
|
|
case !validHeaderValue(key):
|
|
reason = "has a character not allowed in a header"
|
|
case len(key) < MinKeyLength:
|
|
reason = fmt.Sprintf("must be at least %d characters", MinKeyLength)
|
|
}
|
|
if reason != "" {
|
|
return db.AiSetting{}, &ValidationError{Problems: []Problem{{Field: "api_key", Reason: reason}}}
|
|
}
|
|
ct, err := s.box.Encrypt([]byte(key))
|
|
if err != nil {
|
|
return db.AiSetting{}, fmt.Errorf("aisettings: seal AI API key: %w", err)
|
|
}
|
|
row.ApiKeyCt = ct
|
|
row.ApiKeyLast4 = sql.NullString{String: key[len(key)-4:], Valid: true}
|
|
return row, nil
|
|
})
|
|
}
|
|
|
|
// ResetPrompt restores household.DefaultPersonaPrompt as the system prompt.
|
|
func (s *Service) ResetPrompt(ctx context.Context) (Settings, error) {
|
|
return s.change(ctx, func(row db.AiSetting) (db.AiSetting, error) {
|
|
row.SystemPrompt = sql.NullString{String: household.DefaultPersonaPrompt, Valid: true}
|
|
return row, nil
|
|
})
|
|
}
|
|
|
|
// Verify checks the stored key with the provider and, when it is
|
|
// accepted, records key_verified_at. Settings that cannot build a
|
|
// provider fail with ErrNotConfigured; a provider failure matches
|
|
// ErrProviderFailed and the llm typed error the provider returned. If the
|
|
// settings change while the provider is asked, nothing is recorded and the
|
|
// error is ErrChanged.
|
|
func (s *Service) Verify(ctx context.Context) (Settings, error) {
|
|
row, err := s.read(ctx, db.New(s.st.Reader))
|
|
if err != nil {
|
|
return Settings{}, err
|
|
}
|
|
acct, key, err := s.account(row)
|
|
if err != nil {
|
|
return Settings{}, err
|
|
}
|
|
if err := acct.Verify(ctx); err != nil {
|
|
return Settings{}, redact(fmt.Errorf("%w: verify: %w", ErrProviderFailed, err), key)
|
|
}
|
|
verified := store.Now().String()
|
|
return s.change(ctx, func(now db.AiSetting) (db.AiSetting, error) {
|
|
if now.Provider != row.Provider || now.BaseUrl != row.BaseUrl || !bytes.Equal(now.ApiKeyCt, row.ApiKeyCt) {
|
|
return db.AiSetting{}, ErrChanged
|
|
}
|
|
now.KeyVerifiedAt = sql.NullString{String: verified, Valid: true}
|
|
return now, nil
|
|
})
|
|
}
|
|
|
|
// Models returns the provider's live model list with the API key removed
|
|
// from every model's ID and display name. Its errors are Verify's.
|
|
func (s *Service) Models(ctx context.Context) ([]Model, error) {
|
|
row, err := s.read(ctx, db.New(s.st.Reader))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
acct, key, err := s.account(row)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
models, err := acct.ListModels(ctx)
|
|
if err != nil {
|
|
return nil, redact(fmt.Errorf("%w: list models: %w", ErrProviderFailed, err), key)
|
|
}
|
|
out := make([]Model, len(models))
|
|
for i, m := range models {
|
|
out[i] = Model{ID: llm.Redact(m.ID, key), DisplayName: llm.Redact(m.DisplayName, key)}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// HarnessConfig returns the harness.Config for a skill run with the
|
|
// current settings, sharing guard. Read it for each run, so a run always
|
|
// uses the latest saved provider, model, temperature and persona.
|
|
func (s *Service) HarnessConfig(ctx context.Context, guard *harness.Guard) (harness.Config, error) {
|
|
row, err := s.read(ctx, db.New(s.st.Reader))
|
|
if err != nil {
|
|
return harness.Config{}, err
|
|
}
|
|
key, err := s.key(row)
|
|
if err != nil {
|
|
return harness.Config{}, err
|
|
}
|
|
return harness.Config{
|
|
Settings: s.llmSettings(row),
|
|
APIKey: key,
|
|
NewProvider: s.newProvider,
|
|
Persona: row.SystemPrompt.String,
|
|
Store: s.st,
|
|
Guard: guard,
|
|
}, nil
|
|
}
|
|
|
|
// account builds the provider's Account from row, returning the key too
|
|
// so the caller can redact it.
|
|
func (s *Service) account(row db.AiSetting) (presets.Account, string, error) {
|
|
key, err := s.key(row)
|
|
if err != nil {
|
|
return nil, "", err
|
|
}
|
|
acct, err := s.newAccount(s.llmSettings(row), key)
|
|
if err != nil {
|
|
return nil, "", redact(fmt.Errorf("%w: %w", ErrNotConfigured, err), key)
|
|
}
|
|
return acct, key, nil
|
|
}
|
|
|
|
func (s *Service) llmSettings(row db.AiSetting) llm.Settings {
|
|
return llm.Settings{
|
|
Provider: row.Provider,
|
|
BaseURL: row.BaseUrl,
|
|
Model: row.Model,
|
|
Temperature: row.Temperature,
|
|
PublicURL: s.publicURL,
|
|
}
|
|
}
|
|
|
|
// key opens row's API key; "" when none is stored.
|
|
func (s *Service) key(row db.AiSetting) (string, error) {
|
|
if len(row.ApiKeyCt) == 0 {
|
|
return "", nil
|
|
}
|
|
key, err := s.box.Decrypt(row.ApiKeyCt)
|
|
if err != nil {
|
|
return "", fmt.Errorf("aisettings: open AI API key: %w", err)
|
|
}
|
|
return string(key), nil
|
|
}
|
|
|
|
func (s *Service) read(ctx context.Context, q *db.Queries) (db.AiSetting, error) {
|
|
row, err := q.GetAISettings(ctx)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return db.AiSetting{}, errors.New("aisettings: AI settings are not seeded")
|
|
}
|
|
if err != nil {
|
|
return db.AiSetting{}, fmt.Errorf("aisettings: read AI settings: %w", err)
|
|
}
|
|
return row, nil
|
|
}
|
|
|
|
// change saves edit's result for the current row in one transaction.
|
|
func (s *Service) change(ctx context.Context, edit func(db.AiSetting) (db.AiSetting, error)) (Settings, error) {
|
|
var saved db.AiSetting
|
|
err := s.st.WithTx(ctx, func(tx *sql.Tx) error {
|
|
q := db.New(tx)
|
|
row, err := s.read(ctx, q)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
next, err := edit(row)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
saved, err = q.UpsertAISettings(ctx, db.UpsertAISettingsParams{
|
|
Provider: next.Provider,
|
|
BaseUrl: next.BaseUrl,
|
|
Model: next.Model,
|
|
Temperature: next.Temperature,
|
|
SystemPrompt: next.SystemPrompt,
|
|
ApiKeyCt: next.ApiKeyCt,
|
|
ApiKeyLast4: next.ApiKeyLast4,
|
|
KeyVerifiedAt: next.KeyVerifiedAt,
|
|
CreatedAt: row.CreatedAt,
|
|
UpdatedAt: store.Now().String(),
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("aisettings: save AI settings: %w", err)
|
|
}
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return Settings{}, err
|
|
}
|
|
return settingsFrom(saved), nil
|
|
}
|
|
|
|
// applyPatch returns row with patch applied, validated.
|
|
func applyPatch(row db.AiSetting, patch Patch) (db.AiSetting, error) {
|
|
var problems []Problem
|
|
fail := func(field, reason string) {
|
|
problems = append(problems, Problem{Field: field, Reason: reason})
|
|
}
|
|
if patch == (Patch{}) {
|
|
fail("settings", "no settings to change")
|
|
}
|
|
before := row
|
|
if patch.Provider != nil {
|
|
if _, ok := presets.Lookup(*patch.Provider); ok {
|
|
row.Provider = *patch.Provider
|
|
} else {
|
|
fail("provider", "must be one of "+strings.Join(presets.Names(), ", "))
|
|
}
|
|
}
|
|
if patch.BaseURL != nil {
|
|
row.BaseUrl = *patch.BaseURL
|
|
}
|
|
if patch.Model != nil {
|
|
switch m := *patch.Model; {
|
|
case strings.TrimSpace(m) == "":
|
|
fail("model", "must not be empty")
|
|
case !validHeaderValue(m):
|
|
fail("model", "must not contain control characters")
|
|
default:
|
|
row.Model = m
|
|
}
|
|
}
|
|
if patch.Temperature != nil {
|
|
t := *patch.Temperature
|
|
tenths := math.Round(t * 10)
|
|
if t < 0 || t > 1 || math.Abs(t*10-tenths) > 1e-9 {
|
|
fail("temperature", "must be between 0 and 1 in steps of 0.1")
|
|
} else {
|
|
row.Temperature = tenths / 10
|
|
}
|
|
}
|
|
if patch.SystemPrompt != nil {
|
|
if n := utf8.RuneCountInString(*patch.SystemPrompt); n > MaxSystemPromptChars {
|
|
fail("system_prompt", fmt.Sprintf("must be at most %d characters, got %d", MaxSystemPromptChars, n))
|
|
} else {
|
|
row.SystemPrompt = sql.NullString{String: *patch.SystemPrompt, Valid: true}
|
|
}
|
|
}
|
|
if p, ok := presets.Lookup(row.Provider); ok && len(problems) == 0 {
|
|
switch {
|
|
case p.BaseURL != "" && row.BaseUrl != "" && row.BaseUrl != p.BaseURL:
|
|
fail("base_url", fmt.Sprintf("is fixed for %s: must be empty or %s", p.Name, p.BaseURL))
|
|
case p.BaseURL == "" && row.BaseUrl != "":
|
|
// The preset's own check of the URL; its errors never quote it.
|
|
if _, err := presets.ForAccount(llm.Settings{Provider: p.Name, BaseURL: row.BaseUrl}, ""); err != nil {
|
|
fail("base_url", err.Error())
|
|
}
|
|
}
|
|
}
|
|
// The stored key is only ever sent where it was stored for: moving it
|
|
// to another provider or server would let a changed base URL receive
|
|
// it. Remove the key, change the endpoint, then store the new key.
|
|
if len(problems) == 0 && len(row.ApiKeyCt) > 0 {
|
|
if row.Provider != before.Provider {
|
|
fail("provider", "cannot change while an API key is stored: remove the key first")
|
|
}
|
|
if row.BaseUrl != before.BaseUrl {
|
|
fail("base_url", "cannot change while an API key is stored: remove the key first")
|
|
}
|
|
}
|
|
if len(problems) > 0 {
|
|
return db.AiSetting{}, &ValidationError{Problems: problems}
|
|
}
|
|
return row, nil
|
|
}
|
|
|
|
func settingsFrom(row db.AiSetting) Settings {
|
|
return Settings{
|
|
Provider: row.Provider,
|
|
BaseURL: row.BaseUrl,
|
|
Model: row.Model,
|
|
Temperature: row.Temperature,
|
|
SystemPrompt: nullString(row.SystemPrompt),
|
|
APIKeyLast4: nullString(row.ApiKeyLast4),
|
|
KeyVerifiedAt: nullString(row.KeyVerifiedAt),
|
|
UpdatedAt: row.UpdatedAt,
|
|
}
|
|
}
|
|
|
|
func nullString(s sql.NullString) *string {
|
|
if !s.Valid {
|
|
return nil
|
|
}
|
|
return &s.String
|
|
}
|
|
|
|
// secureBaseURL reports whether base is a URL a key may be sent to.
|
|
func secureBaseURL(base string) bool {
|
|
u, err := url.Parse(base)
|
|
return base != "" && err == nil && secureurl.Check(u) == nil
|
|
}
|
|
|
|
// validHeaderValue reports whether value has no control character other
|
|
// than tab.
|
|
func validHeaderValue(value string) bool {
|
|
for i := 0; i < len(value); i++ {
|
|
if c := value[i]; (c < 0x20 && c != '\t') || c == 0x7f {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// redactedError is an error with the API key removed from its text. It
|
|
// does not unwrap, because the original may hold the key; Is still matches
|
|
// the sentinels the original wraps.
|
|
type redactedError struct {
|
|
text string
|
|
err error
|
|
}
|
|
|
|
func (e *redactedError) Error() string { return e.text }
|
|
|
|
// Is reports whether the original error matches target.
|
|
func (e *redactedError) Is(target error) bool { return errors.Is(e.err, target) }
|
|
|
|
// redact returns err with key removed from its text; an empty key returns
|
|
// err unchanged.
|
|
func redact(err error, key string) error {
|
|
if key == "" {
|
|
return err
|
|
}
|
|
return &redactedError{text: llm.Redact(err.Error(), key), err: err}
|
|
}
|