Files
poimen-workflows/internal/routing/provider.go
T
Test 0d70da4f31
ci / test (push) Failing after 3m24s
refactor: make routing system extensible with provider/builder interfaces
BREAKING: LLMRouter now requires explicit LLMProvider

New Abstractions:
- LLMProvider interface: swap providers (OpenAI, Claude, local, etc)
- SpecBuilder interface: custom spec generation strategies
- ParameterBinder interface: flexible parameter resolution
- ActivityExecutor interface: pluggable activity execution
- WorkflowValidator interface: composable validation

Provider System:
- ProviderRegistry: manage multiple LLM providers
- RoutingProviderLLM: fallback across providers
- CachingLLMProvider: caching wrapper
- RetryingLLMProvider: retry wrapper

Spec Building:
- DefaultSpecBuilder: basic spec generation
- CronSpecBuilder: cron workflow specialization
- SpecBuilderFactory: builder selection
- CompositeSpecBuilder: multi-strategy fallback
- BuildMetadata: context for builders

Validators:
- StateGraphValidator: DAG structure
- ActivityAvailabilityValidator: activity existence
- TimeoutValidator: timeout format
- CompositeValidator: multiple validators
- TransitionValidator: state transitions

Refactored Components:
- LLMRouter: config-driven, provider-agnostic
- LLMClient: now implements LLMProvider
- llm_router.go: 97 fewer lines (delegated to builders)

Migration Path:
OLD: NewLLMRouter(kb)
NEW: NewLLMRouter(LLMRouterConfig{Provider: ..., KB: ...})
2026-09-03 09:17:38 -07:00

253 lines
6.4 KiB
Go

package routing
import (
"context"
"fmt"
)
// LLMProvider defines interface for LLM services
type LLMProvider interface {
// Name returns provider name (e.g., "openai", "claude", "local")
Name() string
// Chat sends a message and returns response
Chat(ctx context.Context, systemPrompt, userPrompt string) (string, error)
// IsAvailable checks if provider is configured and reachable
IsAvailable(ctx context.Context) error
}
// ProviderRegistry manages available LLM providers
type ProviderRegistry struct {
providers map[string]LLMProvider
default_ string
}
// NewProviderRegistry creates a new registry
func NewProviderRegistry() *ProviderRegistry {
return &ProviderRegistry{
providers: make(map[string]LLMProvider),
}
}
// Register adds a provider
func (pr *ProviderRegistry) Register(provider LLMProvider) error {
if provider.Name() == "" {
return fmt.Errorf("provider name cannot be empty")
}
pr.providers[provider.Name()] = provider
return nil
}
// SetDefault sets the default provider
func (pr *ProviderRegistry) SetDefault(name string) error {
if _, exists := pr.providers[name]; !exists {
return fmt.Errorf("provider %s not registered", name)
}
pr.default_ = name
return nil
}
// Get retrieves a provider by name
func (pr *ProviderRegistry) Get(name string) (LLMProvider, error) {
if name == "" {
name = pr.default_
}
provider, exists := pr.providers[name]
if !exists {
return nil, fmt.Errorf("provider %s not found", name)
}
return provider, nil
}
// GetDefault returns the default provider
func (pr *ProviderRegistry) GetDefault() (LLMProvider, error) {
if pr.default_ == "" {
return nil, fmt.Errorf("no default provider set")
}
return pr.Get(pr.default_)
}
// RoutingProviderLLM routes between multiple LLM providers with fallback
type RoutingProviderLLM struct {
registry *ProviderRegistry
fallbackOrder []string
}
// NewRoutingProviderLLM creates a routing LLM
func NewRoutingProviderLLM(registry *ProviderRegistry, order ...string) *RoutingProviderLLM {
return &RoutingProviderLLM{
registry: registry,
fallbackOrder: order,
}
}
// Chat tries providers in order
func (rp *RoutingProviderLLM) Chat(ctx context.Context, systemPrompt, userPrompt string) (string, error) {
for _, providerName := range rp.fallbackOrder {
provider, err := rp.registry.Get(providerName)
if err != nil {
continue
}
if err := provider.IsAvailable(ctx); err != nil {
continue
}
response, err := provider.Chat(ctx, systemPrompt, userPrompt)
if err == nil {
return response, nil
}
}
return "", fmt.Errorf("all LLM providers failed")
}
// CachingLLMProvider wraps a provider with caching
type CachingLLMProvider struct {
provider LLMProvider
cache map[string]string
}
// NewCachingLLMProvider creates a cached provider
func NewCachingLLMProvider(provider LLMProvider) *CachingLLMProvider {
return &CachingLLMProvider{
provider: provider,
cache: make(map[string]string),
}
}
// Chat returns cached response if available
func (clp *CachingLLMProvider) Chat(ctx context.Context, systemPrompt, userPrompt string) (string, error) {
key := systemPrompt + "|" + userPrompt
if cached, exists := clp.cache[key]; exists {
return cached, nil
}
response, err := clp.provider.Chat(ctx, systemPrompt, userPrompt)
if err != nil {
return "", err
}
clp.cache[key] = response
return response, nil
}
// IsAvailable delegates to wrapped provider
func (clp *CachingLLMProvider) IsAvailable(ctx context.Context) error {
return clp.provider.IsAvailable(ctx)
}
// Name delegates to wrapped provider
func (clp *CachingLLMProvider) Name() string {
return clp.provider.Name() + "-cached"
}
// RetryingLLMProvider wraps a provider with retry logic
type RetryingLLMProvider struct {
provider LLMProvider
maxRetries int
backoffFunc func(attempt int) interface{}
}
// NewRetryingLLMProvider creates a retrying provider
func NewRetryingLLMProvider(provider LLMProvider, maxRetries int) *RetryingLLMProvider {
return &RetryingLLMProvider{
provider: provider,
maxRetries: maxRetries,
backoffFunc: func(attempt int) interface{} {
// Exponential backoff: 1s, 2s, 4s...
return 1 << uint(attempt)
},
}
}
// Chat retries on failure
func (rlp *RetryingLLMProvider) Chat(ctx context.Context, systemPrompt, userPrompt string) (string, error) {
var lastErr error
for attempt := 0; attempt <= rlp.maxRetries; attempt++ {
response, err := rlp.provider.Chat(ctx, systemPrompt, userPrompt)
if err == nil {
return response, nil
}
lastErr = err
}
return "", fmt.Errorf("failed after %d retries: %w", rlp.maxRetries, lastErr)
}
// IsAvailable delegates to wrapped provider
func (rlp *RetryingLLMProvider) IsAvailable(ctx context.Context) error {
return rlp.provider.IsAvailable(ctx)
}
// Name delegates to wrapped provider
func (rlp *RetryingLLMProvider) Name() string {
return rlp.provider.Name() + "-retrying"
}
// PromptTemplate defines a reusable prompt structure
type PromptTemplate interface {
// Render creates a prompt from values
Render(values map[string]interface{}) (string, error)
}
// SimplePromptTemplate uses Go text/template syntax
type SimplePromptTemplate struct {
template string
}
// NewSimplePromptTemplate creates a simple template
func NewSimplePromptTemplate(template string) *SimplePromptTemplate {
return &SimplePromptTemplate{template: template}
}
// Render renders the template (placeholder implementation)
func (spt *SimplePromptTemplate) Render(values map[string]interface{}) (string, error) {
// In real implementation, use text/template
return spt.template, nil
}
// PromptBuilder builds prompts from components
type PromptBuilder struct {
system string
sections []string
}
// NewPromptBuilder creates a new builder
func NewPromptBuilder() *PromptBuilder {
return &PromptBuilder{
sections: []string{},
}
}
// System sets the system prompt
func (pb *PromptBuilder) System(prompt string) *PromptBuilder {
pb.system = prompt
return pb
}
// AddSection adds a prompt section
func (pb *PromptBuilder) AddSection(title, content string) *PromptBuilder {
if title != "" {
pb.sections = append(pb.sections, fmt.Sprintf("## %s\n%s", title, content))
} else {
pb.sections = append(pb.sections, content)
}
return pb
}
// Build returns the complete prompt
func (pb *PromptBuilder) Build() (system, user string) {
user = ""
for i, section := range pb.sections {
if i > 0 {
user += "\n\n"
}
user += section
}
return pb.system, user
}