Files
noema/internal/llm/router/router.go
T
Andy Dixon f165adc5f2 feat(llm): Amazon Bedrock provider over a standard-library SigV4 signer
Kind `bedrock` speaks the Converse API for chat roles and InvokeModel for
Titan and Cohere embeddings. Requests are signed with AWS Signature
Version 4 implemented with crypto/hmac and crypto/sha256 only; the signer
is pinned by tests to signatures botocore produces for the same requests,
with and without a session token, so drift fails the build.

The credential is one secret reference, ACCESS_KEY_ID:SECRET_ACCESS_KEY
[:SESSION_TOKEN], encrypted together like every other provider key. The
region is read from the endpoint host, which must be a Bedrock runtime
host over HTTPS unless private endpoints are explicitly allowed; other
hosts are refused. Provider errors are scrubbed of the secret and token.

Migration 0024 widens the provider kind constraint; existing rows are
untouched and the down migration refuses while bedrock rows exist rather
than dropping configuration. UI kind selects, docs/llm-providers.md,
ADR-0010, ARCHITECTURE and the changelog are updated.
2026-08-24 22:03:48 +01:00

422 lines
13 KiB
Go

// Package router maps cognitive roles to configured providers and models,
// applies budgets and concurrency limits, records every call and serves
// recordings during replay (ADR-0004).
package router
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"log/slog"
"sync"
"time"
"github.com/andydixon/noema/internal/embeddings"
"github.com/andydixon/noema/internal/llm"
"github.com/andydixon/noema/internal/llm/providers"
"github.com/andydixon/noema/internal/observability"
"github.com/andydixon/noema/internal/scheduler"
"github.com/andydixon/noema/internal/security/secrets"
"github.com/andydixon/noema/pkg/ids"
)
// Router resolves roles to configured models, applies budgets and
// concurrency limits, records every call and emits metrics. Cognitive code
// depends on this type only.
type Router struct {
store *Store
secrets *secrets.Resolver
log *slog.Logger
metrics *observability.Metrics
pool *scheduler.Pool
mu sync.RWMutex
cache routing
cachedAt time.Time
DailyTokenBudget int64 // global hard stop (0 = unlimited)
MindBudget func(mind ids.ID) int64 // per-mind daily budget lookup (nil = unlimited)
Replay bool // serve recorded responses when a matching call exists
// MindRoles returns a mind's own role → model overrides (from its cognitive
// configuration). An override names exactly one model and no fallback:
// an experiment comparing providers must fail closed rather than
// silently fall back to the daemon default.
MindRoles func(ctx context.Context, mind ids.ID) map[llm.Role]ids.ID
Override map[llm.Role]llm.LanguageModel // tests inject models per role
now func() time.Time
}
type routing struct {
providers map[ids.ID]Provider
models map[ids.ID]Model
roles map[llm.Role]RoleAssignment
}
// NewRouter creates a router.
func NewRouter(store *Store, sec *secrets.Resolver, log *slog.Logger, metrics *observability.Metrics, maxConcurrent int) *Router {
return &Router{store: store, secrets: sec, log: log, metrics: metrics, pool: scheduler.NewPool(maxConcurrent), now: time.Now}
}
// Invalidate forces the configuration cache to reload on the next call.
func (r *Router) Invalidate() {
r.mu.Lock()
r.cachedAt = time.Time{}
r.mu.Unlock()
}
func (r *Router) load(ctx context.Context) (routing, error) {
r.mu.RLock()
if time.Since(r.cachedAt) < 30*time.Second {
c := r.cache
r.mu.RUnlock()
return c, nil
}
r.mu.RUnlock()
provs, err := r.store.ListProviders(ctx)
if err != nil {
return routing{}, err
}
models, err := r.store.ListModels(ctx)
if err != nil {
return routing{}, err
}
roles, err := r.store.ListRoles(ctx)
if err != nil {
return routing{}, err
}
c := routing{providers: map[ids.ID]Provider{}, models: map[ids.ID]Model{}, roles: map[llm.Role]RoleAssignment{}}
for _, p := range provs {
c.providers[p.ID] = p
}
for _, m := range models {
c.models[m.ID] = m
}
for _, a := range roles {
c.roles[a.Role] = a
}
r.mu.Lock()
r.cache, r.cachedAt = c, time.Now()
r.mu.Unlock()
return c, nil
}
// Candidates returns the ordered models (primary then fallbacks) for a role.
func (r *Router) Candidates(ctx context.Context, role llm.Role) ([]Model, error) {
c, err := r.load(ctx)
if err != nil {
return nil, err
}
a, ok := c.roles[role]
if !ok || a.ModelID == nil {
return nil, llm.ErrNoModel
}
var out []Model
for _, id := range append([]ids.ID{*a.ModelID}, a.FallbackIDs...) {
m, ok := c.models[id]
if !ok || !m.Enabled {
continue
}
p, ok := c.providers[m.ProviderID]
if !ok || !p.Enabled {
continue
}
out = append(out, m)
}
if len(out) == 0 {
return nil, llm.ErrNoModel
}
return out, nil
}
// Available reports whether a role has at least one usable model.
func (r *Router) Available(ctx context.Context, role llm.Role) bool {
if r.Override != nil {
if _, ok := r.Override[role]; ok {
return true
}
}
_, err := r.Candidates(ctx, role)
return err == nil
}
// build constructs a provider client for a model, resolving the key at call time.
func (r *Router) build(c routing, m Model) (llm.LanguageModel, error) {
p := c.providers[m.ProviderID]
key, err := r.secrets.Resolve(p.APIKeyRef)
if err != nil {
return nil, fmt.Errorf("provider %s: %w", p.Name, err)
}
cfg := providers.Config{Name: p.Name, Endpoint: p.Endpoint, APIKey: key, Model: m.Name, Timeout: p.Timeout, AllowPrivate: p.AllowPrivate, Headers: p.Headers}
switch p.Kind {
case llm.KindOpenAI:
return providers.NewOpenAI(cfg), nil
case llm.KindAnthropic:
return providers.NewAnthropic(cfg), nil
case llm.KindOllama:
return providers.NewOllama(cfg), nil
case llm.KindBedrock:
return providers.NewBedrock(cfg)
}
return nil, fmt.Errorf("provider %s: unsupported kind %s", p.Name, p.Kind)
}
// Embedder returns the embedder configured for llm.RoleEmbed, or embeddings.None.
func (r *Router) Embedder(ctx context.Context) embeddings.Embedder {
c, err := r.load(ctx)
if err != nil {
return embeddings.None{}
}
a, ok := c.roles[llm.RoleEmbed]
if !ok || a.ModelID == nil {
return embeddings.None{}
}
m, ok := c.models[*a.ModelID]
if !ok || !m.Enabled {
return embeddings.None{}
}
p, ok := c.providers[m.ProviderID]
if !ok || !p.Enabled {
return embeddings.None{}
}
key, err := r.secrets.Resolve(p.APIKeyRef)
if err != nil {
return embeddings.None{}
}
cfg := providers.Config{Name: p.Name, Endpoint: p.Endpoint, APIKey: key, Model: m.Name, Timeout: p.Timeout, AllowPrivate: p.AllowPrivate, Headers: p.Headers}
switch p.Kind {
case llm.KindOllama:
return providers.NewOllamaEmbedder(cfg, m.Dims)
case llm.KindBedrock:
if e, err := providers.NewBedrockEmbedder(cfg, m.Dims); err == nil {
return e
}
return embeddings.None{}
case llm.KindOpenAI:
return providers.NewOpenAIEmbedder(cfg, m.Dims)
}
return embeddings.None{}
}
// Hash returns the deterministic request hash used for replay matching. It
// covers everything that influences the model's answer.
func Hash(model string, req llm.Request) string {
h := sha256.New()
enc := json.NewEncoder(h)
enc.Encode(struct { //nolint:errcheck
Model string
Req llm.Request
}{model, req})
return hex.EncodeToString(h.Sum(nil))[:32]
}
// Complete routes a request for a role: budget check, primary then fallbacks,
// recording and metrics. The returned response carries the call id so events
// can cite it.
func (r *Router) Complete(ctx context.Context, req llm.Request) (llm.Response, error) {
if req.Role == "" {
return llm.Response{}, fmt.Errorf("%w: role required", ErrInvalid)
}
if r.Override != nil {
if lm, ok := r.Override[req.Role]; ok {
return r.call(ctx, lm, "override", "override", 0, 0, req)
}
}
c, err := r.load(ctx)
if err != nil {
return llm.Response{}, err
}
cands, err := r.candidatesFor(ctx, c, req.Role, req.MindID)
if err != nil {
return llm.Response{}, err
}
if err := r.checkBudget(ctx, req.MindID); err != nil {
return llm.Response{}, err
}
var lastErr error
for i, m := range cands {
lm, err := r.build(c, m)
if err != nil {
lastErr = err
continue
}
applyDefaults(&req, c.roles[req.Role], m)
resp, err := r.call(ctx, lm, m.Provider, m.Name, m.InputCostPerM, m.OutputCostPerM, req)
if err == nil {
return resp, nil
}
lastErr = err
if errors.Is(err, context.Canceled) || errors.Is(err, llm.ErrBudgetExceeded) {
break
}
if i < len(cands)-1 {
r.log.Warn("model failed; trying fallback", "role", req.Role, "model", m.Name, "err", err)
}
}
return llm.Response{}, lastErr
}
func applyDefaults(req *llm.Request, a RoleAssignment, m Model) {
if req.Temperature == nil {
if a.Temperature != nil {
req.Temperature = a.Temperature
} else if t, ok := m.Params["temperature"].(float64); ok {
req.Temperature = &t
}
}
if req.MaxTokens == 0 {
if a.MaxTokens != nil {
req.MaxTokens = *a.MaxTokens
} else if t, ok := m.Params["max_tokens"].(float64); ok {
req.MaxTokens = int(t)
}
}
}
func (r *Router) checkBudget(ctx context.Context, mind ids.ID) error {
dayStart := r.now().Truncate(24 * time.Hour)
if r.DailyTokenBudget > 0 {
used, err := r.store.TokensSince(ctx, dayStart, ids.Nil)
if err != nil {
return err
}
if used >= r.DailyTokenBudget {
return fmt.Errorf("%w: global daily budget %d reached", llm.ErrBudgetExceeded, r.DailyTokenBudget)
}
}
if r.MindBudget != nil && !mind.IsZero() {
if b := r.MindBudget(mind); b > 0 {
used, err := r.store.TokensSince(ctx, dayStart, mind)
if err != nil {
return err
}
if used >= b {
return fmt.Errorf("%w: mind daily budget %d reached", llm.ErrBudgetExceeded, b)
}
}
}
return nil
}
func (r *Router) call(ctx context.Context, lm llm.LanguageModel, provider, model string, inCost, outCost float64, req llm.Request) (llm.Response, error) {
hash := Hash(model, req)
reqJSON, _ := json.Marshal(req)
if r.Replay {
if rec, err := r.store.FindRecorded(ctx, hash); err == nil && len(rec.Response) > 0 {
var resp llm.Response
if json.Unmarshal(rec.Response, &resp) == nil {
resp.Replayed, resp.CallID = true, ids.New()
r.store.RecordCall(ctx, Call{ID: resp.CallID, MindID: req.MindID, CorrelationID: req.CorrelationID, Role: req.Role, Purpose: req.Purpose, Provider: provider, Model: model, //nolint:errcheck
PromptName: req.PromptName, PromptVersion: req.PromptVersion, RequestHash: hash, Request: reqJSON, Response: rec.Response, Replayed: true, Seed: req.Seed})
return resp, nil
}
}
}
var resp llm.Response
var callErr error
start := time.Now()
err := r.pool.Run(ctx, func(ctx context.Context) error {
cctx := ctx
if req.Timeout > 0 {
var cancel context.CancelFunc
cctx, cancel = context.WithTimeout(ctx, req.Timeout)
defer cancel()
}
resp, callErr = lm.Complete(cctx, req)
return nil
})
if err != nil {
return llm.Response{}, err
}
latency := time.Since(start)
call := Call{ID: ids.New(), MindID: req.MindID, CorrelationID: req.CorrelationID, Role: req.Role, Purpose: req.Purpose, Provider: provider, Model: model,
PromptName: req.PromptName, PromptVersion: req.PromptVersion, RequestHash: hash, Request: reqJSON, LatencyMS: int(latency.Milliseconds()), Seed: req.Seed}
if r.metrics != nil {
r.metrics.LLMRequests.WithLabelValues(provider, string(req.Role)).Inc()
}
if callErr != nil {
call.Error = callErr.Error()
if r.metrics != nil {
r.metrics.LLMFailures.WithLabelValues(provider).Inc()
}
r.store.RecordCall(ctx, call) //nolint:errcheck
return llm.Response{}, callErr
}
resp.CallID, resp.Latency = call.ID, latency
resp.Cost = float64(resp.Usage.PromptTokens)*inCost/1e6 + float64(resp.Usage.CompletionTokens)*outCost/1e6
call.PromptTokens, call.OutputTokens, call.Cost = resp.Usage.PromptTokens, resp.Usage.CompletionTokens, resp.Cost
call.Response, _ = json.Marshal(resp)
if r.metrics != nil {
r.metrics.LLMTokens.WithLabelValues(provider, "prompt").Add(float64(resp.Usage.PromptTokens))
r.metrics.LLMTokens.WithLabelValues(provider, "completion").Add(float64(resp.Usage.CompletionTokens))
}
if err := r.store.RecordCall(ctx, call); err != nil {
r.log.Error("record llm call failed", "err", err)
}
return resp, nil
}
// TestProvider performs a tiny completion against the first enabled chat model
// of a provider (or the given model) and records the outcome. It never returns
// secret material.
func (r *Router) TestProvider(ctx context.Context, providerID ids.ID, modelName string) (llm.Response, error) {
c, err := r.load(ctx)
if err != nil {
return llm.Response{}, err
}
p, ok := c.providers[providerID]
if !ok {
return llm.Response{}, fmt.Errorf("%w: provider not found", ErrInvalid)
}
var m Model
for _, cand := range c.models {
if cand.ProviderID == providerID && cand.Kind == "chat" && (modelName == "" || cand.Name == modelName) {
m = cand
break
}
}
if m.ID.IsZero() {
m = Model{ProviderID: providerID, Provider: p.Name, Name: modelName}
}
if m.Name == "" {
return llm.Response{}, fmt.Errorf("%w: add a chat model to test this provider", ErrInvalid)
}
lm, err := r.build(c, m)
if err != nil {
r.store.RecordProviderTest(ctx, providerID, false, err.Error()) //nolint:errcheck
return llm.Response{}, err
}
tctx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
resp, err := r.call(tctx, lm, p.Name, m.Name, m.InputCostPerM, m.OutputCostPerM, llm.Request{Role: llm.RoleJudge, Purpose: "connectivity test", MaxTokens: 8, Messages: []llm.Message{{Role: "user", Content: "Reply with the single word OK."}}})
if err != nil {
r.store.RecordProviderTest(ctx, providerID, false, err.Error()) //nolint:errcheck
return llm.Response{}, err
}
r.store.RecordProviderTest(ctx, providerID, true, fmt.Sprintf("%s answered in %dms", resp.Model, resp.Latency.Milliseconds())) //nolint:errcheck
r.Invalidate()
return resp, nil
}
// candidatesFor resolves the models to try for a role, honouring a mind's
// own override when it has one.
func (r *Router) candidatesFor(ctx context.Context, c routing, role llm.Role, mind ids.ID) ([]Model, error) {
if r.MindRoles != nil && !mind.IsZero() {
if overrides := r.MindRoles(ctx, mind); overrides != nil {
if id, ok := overrides[role]; ok {
m, found := c.models[id]
if !found || !m.Enabled {
return nil, fmt.Errorf("%w: mind override for %s names model %s, which is missing or disabled", llm.ErrNoModel, role, id)
}
if p, ok := c.providers[m.ProviderID]; !ok || !p.Enabled {
return nil, fmt.Errorf("%w: mind override for %s names a disabled provider", llm.ErrNoModel, role)
}
return []Model{m}, nil
}
}
}
return r.Candidates(ctx, role)
}