Skip to content

Commit d7a1415

Browse files
committed
feat(llm): add mock LLM provider and improve caching
Added MockProvider for testing LLM interactions with expectation-based responses. Enhanced caching middleware to store full token metrics (input, output, reasoning, cache read/write). New mock backend supports condition-based prompt matching for flexible test scenarios.
1 parent 6756df1 commit d7a1415

7 files changed

Lines changed: 851 additions & 96 deletions

File tree

go.mod

Lines changed: 104 additions & 95 deletions
Large diffs are not rendered by default.

go.sum

Lines changed: 591 additions & 0 deletions
Large diffs are not rendered by default.

llm/client.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,10 @@ func NewClientWithModel(model string, options ...middleware.Option) (Client, err
3333
return nil, fmt.Errorf("model cannot be empty")
3434
}
3535

36+
if os.Getenv("MOCK") != "false" {
37+
return &directClient{provider: NewMockProvider(model)}, nil
38+
}
39+
3640
// Infer provider backend from model name
3741
backend, err := inferBackendFromModel(model)
3842
if err != nil {

llm/middleware/caching.go

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,13 @@ import (
1010
"github.com/flanksource/commons/logger"
1111
)
1212

13+
func valueOrZero(v *int) int {
14+
if v == nil {
15+
return 0
16+
}
17+
return *v
18+
}
19+
1320
// CacheConfig holds configuration for caching middleware
1421
type CacheConfig struct {
1522
Cache *cache.Cache // Cache instance (required)
@@ -87,7 +94,7 @@ func (c *cachingProvider) Execute(sess *Session, req ProviderRequest) (ProviderR
8794
// Cache error
8895
return ProviderResponse{}, fmt.Errorf("failed to get cache: %w", err)
8996
}
90-
logger.Infof("[%s/%s] cache miss", req.Model)
97+
logger.Infof("[%s/%s] cache miss", c.GetBackend(), req.Model)
9198

9299
// Cache miss - execute request
93100
startTime := time.Now()
@@ -113,6 +120,22 @@ func (c *cachingProvider) Execute(sess *Session, req ProviderRequest) (ProviderR
113120
return resp, execErr
114121
}
115122

123+
cacheEntry.Response = resp.Text
124+
cacheEntry.TokensInput = resp.InputTokens
125+
cacheEntry.TokensOutput = resp.OutputTokens
126+
cacheEntry.TokensReasoning = valueOrZero(resp.ReasoningTokens)
127+
cacheEntry.TokensCacheRead = valueOrZero(resp.CacheReadTokens)
128+
cacheEntry.TokensCacheWrite = valueOrZero(resp.CacheWriteTokens)
129+
cacheEntry.TokensTotal = resp.InputTokens +
130+
resp.OutputTokens +
131+
valueOrZero(resp.ReasoningTokens) +
132+
valueOrZero(resp.CacheReadTokens) +
133+
valueOrZero(resp.CacheWriteTokens)
134+
cacheEntry.Provider = string(c.GetBackend())
135+
if req.MaxTokens != nil {
136+
cacheEntry.MaxTokens = *req.MaxTokens
137+
}
138+
116139
// Store in cache
117140
if err := c.cache.Set(cacheEntry); err != nil {
118141
return resp, fmt.Errorf("failed to set cache: %w", err)

llm/provider_mock.go

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
package llm
2+
3+
import (
4+
"strings"
5+
"sync"
6+
7+
. "github.com/flanksource/commons-db/llm/types"
8+
)
9+
10+
// MockExpectation defines a prompt match rule and its canned response.
11+
type MockExpectation struct {
12+
Contains string
13+
Response string
14+
}
15+
16+
var (
17+
mockExpectations []MockExpectation
18+
mockMu sync.RWMutex
19+
)
20+
21+
// MockWhen registers an expectation: when a prompt contains the given
22+
// substring, return the specified response text.
23+
func MockWhen(contains, response string) {
24+
mockMu.Lock()
25+
defer mockMu.Unlock()
26+
mockExpectations = append(mockExpectations, MockExpectation{Contains: contains, Response: response})
27+
}
28+
29+
// MockReset clears all registered mock expectations.
30+
func MockReset() {
31+
mockMu.Lock()
32+
defer mockMu.Unlock()
33+
mockExpectations = nil
34+
}
35+
36+
type mockProvider struct {
37+
model string
38+
}
39+
40+
// NewMockProvider creates a mock provider that returns canned responses.
41+
func NewMockProvider(model string) Provider {
42+
return &mockProvider{model: model}
43+
}
44+
45+
func (p *mockProvider) Execute(_ *Session, req ProviderRequest) (ProviderResponse, error) {
46+
mockMu.RLock()
47+
defer mockMu.RUnlock()
48+
49+
for _, exp := range mockExpectations {
50+
if strings.Contains(req.Prompt, exp.Contains) {
51+
return ProviderResponse{Text: exp.Response, Model: p.model}, nil
52+
}
53+
}
54+
55+
return ProviderResponse{Text: "mock response", Model: p.model}, nil
56+
}
57+
58+
func (p *mockProvider) GetModel() string { return p.model }
59+
func (p *mockProvider) GetBackend() LLMBackend { return LLMBackendMock }
60+
func (p *mockProvider) GetOpenRouterModelID() string { return "" }

llm/provider_mock_test.go

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
package llm
2+
3+
import (
4+
"testing"
5+
6+
. "github.com/flanksource/commons-db/llm/types"
7+
)
8+
9+
func TestMockProvider_DefaultResponse(t *testing.T) {
10+
p := NewMockProvider("test-model")
11+
resp, err := p.Execute(&Session{}, ProviderRequest{Prompt: "anything"})
12+
if err != nil {
13+
t.Fatal(err)
14+
}
15+
if resp.Text != "mock response" {
16+
t.Errorf("got %q, want %q", resp.Text, "mock response")
17+
}
18+
if resp.Model != "test-model" {
19+
t.Errorf("model = %q, want %q", resp.Model, "test-model")
20+
}
21+
}
22+
23+
func TestMockProvider_Expectations(t *testing.T) {
24+
MockReset()
25+
defer MockReset()
26+
27+
MockWhen("analyze commit", "type: feat\nscope: test\nsubject: mock commit")
28+
MockWhen("summarize", "name: Summary\ndescription: Mock summary")
29+
30+
p := NewMockProvider("test-model")
31+
32+
resp, _ := p.Execute(&Session{}, ProviderRequest{Prompt: "please analyze commit abc123"})
33+
if resp.Text != "type: feat\nscope: test\nsubject: mock commit" {
34+
t.Errorf("got %q", resp.Text)
35+
}
36+
37+
resp, _ = p.Execute(&Session{}, ProviderRequest{Prompt: "summarize the changes"})
38+
if resp.Text != "name: Summary\ndescription: Mock summary" {
39+
t.Errorf("got %q", resp.Text)
40+
}
41+
42+
resp, _ = p.Execute(&Session{}, ProviderRequest{Prompt: "no match here"})
43+
if resp.Text != "mock response" {
44+
t.Errorf("unmatched should return default, got %q", resp.Text)
45+
}
46+
}
47+
48+
func TestMockReset(t *testing.T) {
49+
MockWhen("test", "response")
50+
MockReset()
51+
52+
p := NewMockProvider("m")
53+
resp, _ := p.Execute(&Session{}, ProviderRequest{Prompt: "test"})
54+
if resp.Text != "mock response" {
55+
t.Errorf("after reset, expected default response, got %q", resp.Text)
56+
}
57+
}
58+
59+
func TestMockProvider_Backend(t *testing.T) {
60+
p := NewMockProvider("m")
61+
if p.GetBackend() != LLMBackendMock {
62+
t.Errorf("backend = %q, want %q", p.GetBackend(), LLMBackendMock)
63+
}
64+
if p.GetOpenRouterModelID() != "" {
65+
t.Errorf("OpenRouter ID should be empty")
66+
}
67+
}

llm/types/api.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ const (
1212
LLMBackendAnthropic LLMBackend = "anthropic"
1313
LLMBackendGemini LLMBackend = "gemini"
1414
LLMBackendClaudeCode LLMBackend = "claude-code"
15+
LLMBackendMock LLMBackend = "mock"
1516
)
1617

1718
// Provider is the interface that all LLM provider implementations must satisfy.

0 commit comments

Comments
 (0)