Files
majordomo/provider/fake/fake.go
T
steve dcd004289f feat: foundations — canonical types, Parse grammar, env DSNs, health, chains
Phase 1 of the majordomo build:
- llm/ canonical contract (messages, parts, tools, capabilities, streaming,
  Model/Provider, error classification)
- health/ clock-injected tracker (threshold bench, exponential capped
  cooldown, reset-on-success)
- root Registry + Parse (verbatim model ids, inline recursive alias
  expansion with cycle detection, chain dedup), LLM_* env-DSN providers
  (go-llm parity: lazy fallback + eager LoadEnv), health-aware chain
  executor behind the Model interface
- provider/fake scriptable test provider; hermetic test suite incl. the
  trailing-thinking chain and foreman:// env loading
- ADRs 0001-0008, CLAUDE.md, README (honest matrix), CI workflow,
  docs/phase-1-design.md

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 12:35:34 +02:00

231 lines
6.0 KiB
Go

// Package fake provides an in-memory llm.Provider for hermetic tests.
//
// Why: the resolver, env-DSN loader, chain executor, health tracker, agent
// loop, and skill composition must all be testable with no live API calls.
// The fake provider scripts responses and errors per model id, records every
// request it receives, and supports tools, structured output, and streaming
// well enough to drive those layers deterministically.
package fake
import (
"context"
"fmt"
"io"
"sync"
"gitea.stevedudenhoeffer.com/steve/majordomo/llm"
)
// Step is one scripted outcome: either a response or an error.
type Step struct {
Response *llm.Response
Err error
}
// Reply scripts a successful text response.
func Reply(text string) Step {
return Step{Response: &llm.Response{
Parts: []llm.Part{llm.Text(text)},
FinishReason: llm.FinishStop,
Usage: llm.Usage{InputTokens: 1, OutputTokens: 1},
}}
}
// ReplyWith scripts an arbitrary successful response.
func ReplyWith(resp llm.Response) Step { return Step{Response: &resp} }
// Fail scripts an error outcome.
func Fail(err error) Step { return Step{Err: err} }
// Call records one request received by the fake, with the model id it was
// addressed to.
type Call struct {
ModelID string
Request llm.Request
}
// Provider is a scriptable in-memory llm.Provider.
//
// Outcomes are enqueued per model id with Enqueue. A model whose queue is
// empty falls back to the provider default response (a fixed text reply).
// All methods are safe for concurrent use.
type Provider struct {
name string
mu sync.Mutex
caps llm.Capabilities
modelCaps map[string]llm.Capabilities
queues map[string][]Step
calls []Call
defaultFn func(modelID string, req llm.Request) Step
}
// Option configures the fake provider.
type Option func(*Provider)
// WithCapabilities sets the provider-default capabilities.
func WithCapabilities(caps llm.Capabilities) Option {
return func(p *Provider) { p.caps = caps }
}
// WithModelCapabilities overrides capabilities for one model id.
func WithModelCapabilities(modelID string, caps llm.Capabilities) Option {
return func(p *Provider) { p.modelCaps[modelID] = caps }
}
// WithDefault sets the outcome used when a model's queue is empty.
func WithDefault(fn func(modelID string, req llm.Request) Step) Option {
return func(p *Provider) { p.defaultFn = fn }
}
// New creates a fake provider with the given registry name.
func New(name string, opts ...Option) *Provider {
p := &Provider{
name: name,
modelCaps: make(map[string]llm.Capabilities),
queues: make(map[string][]Step),
caps: llm.Capabilities{
SupportsTools: true,
SupportsStructured: true,
SupportsStreaming: true,
MaxImagesPerReq: 4,
},
defaultFn: func(modelID string, _ llm.Request) Step {
return Reply(fmt.Sprintf("fake response from %s", modelID))
},
}
for _, opt := range opts {
opt(p)
}
return p
}
// Name implements llm.Provider.
func (p *Provider) Name() string { return p.name }
// Model implements llm.Provider. Any id is accepted.
func (p *Provider) Model(id string, opts ...llm.ModelOption) (llm.Model, error) {
cfg := llm.ApplyModelOptions(opts)
return &model{provider: p, id: id, cfg: cfg}, nil
}
// Enqueue appends scripted outcomes for a model id.
func (p *Provider) Enqueue(modelID string, steps ...Step) {
p.mu.Lock()
defer p.mu.Unlock()
p.queues[modelID] = append(p.queues[modelID], steps...)
}
// Calls returns a copy of every request received so far.
func (p *Provider) Calls() []Call {
p.mu.Lock()
defer p.mu.Unlock()
out := make([]Call, len(p.calls))
copy(out, p.calls)
return out
}
// CallCount returns the number of requests received for one model id.
func (p *Provider) CallCount(modelID string) int {
p.mu.Lock()
defer p.mu.Unlock()
n := 0
for _, c := range p.calls {
if c.ModelID == modelID {
n++
}
}
return n
}
// next records the call and pops the next scripted outcome.
func (p *Provider) next(modelID string, req llm.Request) Step {
p.mu.Lock()
defer p.mu.Unlock()
p.calls = append(p.calls, Call{ModelID: modelID, Request: req})
q := p.queues[modelID]
if len(q) == 0 {
return p.defaultFn(modelID, req)
}
step := q[0]
p.queues[modelID] = q[1:]
return step
}
func (p *Provider) capsFor(modelID string, cfg llm.ModelConfig) llm.Capabilities {
if cfg.Capabilities != nil {
return *cfg.Capabilities
}
p.mu.Lock()
defer p.mu.Unlock()
if caps, ok := p.modelCaps[modelID]; ok {
return caps
}
return p.caps
}
type model struct {
provider *Provider
id string
cfg llm.ModelConfig
}
func (m *model) Generate(ctx context.Context, req llm.Request, opts ...llm.Option) (*llm.Response, error) {
if err := ctx.Err(); err != nil {
return nil, err
}
req = req.Apply(opts...)
step := m.provider.next(m.id, req)
if step.Err != nil {
return nil, step.Err
}
resp := *step.Response
if resp.Model == "" {
resp.Model = m.provider.name + "/" + m.id
}
return &resp, nil
}
func (m *model) Stream(ctx context.Context, req llm.Request, opts ...llm.Option) (llm.Stream, error) {
resp, err := m.Generate(ctx, req, opts...)
if err != nil {
return nil, err
}
// Deliver the response as a small sequence of events: one text delta per
// part, one event per tool call, then the final response.
var events []llm.StreamEvent
for _, part := range resp.Parts {
if t, ok := part.(llm.TextPart); ok {
events = append(events, llm.StreamEvent{TextDelta: t.Text})
}
}
for i := range resp.ToolCalls {
events = append(events, llm.StreamEvent{ToolCall: &resp.ToolCalls[i]})
}
events = append(events, llm.StreamEvent{Response: resp})
return &stream{events: events}, nil
}
func (m *model) Capabilities() llm.Capabilities {
return m.provider.capsFor(m.id, m.cfg)
}
type stream struct {
mu sync.Mutex
events []llm.StreamEvent
pos int
}
func (s *stream) Next() (llm.StreamEvent, error) {
s.mu.Lock()
defer s.mu.Unlock()
if s.pos >= len(s.events) {
return llm.StreamEvent{}, io.EOF
}
ev := s.events[s.pos]
s.pos++
return ev, nil
}
func (s *stream) Close() error { return nil }