dcd004289f
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>
231 lines
6.0 KiB
Go
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 }
|