mirror of
https://github.com/j3ssie/osmedeus.git
synced 2026-08-21 15:12:29 +02:00
- Add LLM executor supporting OpenAI vision, tool calling, embeddings, and structured outputs - Introduce event emitter/receiver workflows with deduplication and filtering (generate_event functions) - Add workflow extends/override system enabling inheritance chains and step merge modes - Update function naming to snake_case across all testdata (fileExists→file_exists, etc.) - Add comprehensive test fixtures for linter, events, CDN, step dependencies, and extends workflows
866 lines
24 KiB
Go
866 lines
24 KiB
Go
package executor
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/charmbracelet/glamour"
|
|
"github.com/j3ssie/osmedeus/v5/internal/config"
|
|
"github.com/j3ssie/osmedeus/v5/internal/core"
|
|
"github.com/j3ssie/osmedeus/v5/internal/logger"
|
|
"github.com/j3ssie/osmedeus/v5/internal/metrics"
|
|
"github.com/j3ssie/osmedeus/v5/internal/template"
|
|
"go.uber.org/zap"
|
|
)
|
|
|
|
// LLMExecutor executes LLM steps
|
|
type LLMExecutor struct {
|
|
templateEngine template.TemplateEngine
|
|
client *http.Client
|
|
config *config.Config
|
|
silent bool
|
|
}
|
|
|
|
// NewLLMExecutor creates a new LLM executor
|
|
func NewLLMExecutor(engine template.TemplateEngine) *LLMExecutor {
|
|
return &LLMExecutor{
|
|
templateEngine: engine,
|
|
client: &http.Client{
|
|
Timeout: 120 * time.Second,
|
|
},
|
|
}
|
|
}
|
|
|
|
// Name returns the executor name for logging/debugging
|
|
func (e *LLMExecutor) Name() string {
|
|
return "llm"
|
|
}
|
|
|
|
// StepTypes returns the step types this executor handles
|
|
func (e *LLMExecutor) StepTypes() []core.StepType {
|
|
return []core.StepType{core.StepTypeLLM}
|
|
}
|
|
|
|
// SetConfig sets the application config for LLM settings
|
|
func (e *LLMExecutor) SetConfig(cfg *config.Config) {
|
|
e.config = cfg
|
|
}
|
|
|
|
// SetSilent enables or disables silent mode (suppresses output)
|
|
func (e *LLMExecutor) SetSilent(s bool) {
|
|
e.silent = s
|
|
}
|
|
|
|
// CanHandle returns true if this executor can handle the given step type
|
|
func (e *LLMExecutor) CanHandle(stepType core.StepType) bool {
|
|
return stepType == core.StepTypeLLM
|
|
}
|
|
|
|
// MergedLLMConfig holds the final merged configuration
|
|
type MergedLLMConfig struct {
|
|
Model string
|
|
MaxTokens int
|
|
Temperature float64
|
|
TopK int
|
|
TopP float64
|
|
N int
|
|
Timeout string
|
|
MaxRetries int
|
|
Stream bool
|
|
ResponseFormat *core.LLMResponseFormat
|
|
CustomHeaders map[string]string
|
|
SystemPrompt string
|
|
}
|
|
|
|
// ChatCompletionRequest is the OpenAI-compatible request format
|
|
type ChatCompletionRequest struct {
|
|
Model string `json:"model"`
|
|
Messages []ChatMessage `json:"messages"`
|
|
MaxTokens int `json:"max_tokens,omitempty"`
|
|
Temperature float64 `json:"temperature,omitempty"`
|
|
TopP float64 `json:"top_p,omitempty"`
|
|
TopK int `json:"top_k,omitempty"`
|
|
N int `json:"n,omitempty"`
|
|
Stream bool `json:"stream,omitempty"`
|
|
Tools []core.LLMTool `json:"tools,omitempty"`
|
|
ToolChoice interface{} `json:"tool_choice,omitempty"`
|
|
ResponseFormat *core.LLMResponseFormat `json:"response_format,omitempty"`
|
|
}
|
|
|
|
// ChatMessage is the wire format for messages
|
|
type ChatMessage struct {
|
|
Role string `json:"role"`
|
|
Content interface{} `json:"content"` // string or []ContentPart
|
|
Name string `json:"name,omitempty"`
|
|
ToolCallID string `json:"tool_call_id,omitempty"`
|
|
ToolCalls []core.LLMToolCall `json:"tool_calls,omitempty"`
|
|
}
|
|
|
|
// ChatCompletionResponse is the OpenAI-compatible response format
|
|
type ChatCompletionResponse struct {
|
|
ID string `json:"id"`
|
|
Object string `json:"object"`
|
|
Created int64 `json:"created"`
|
|
Model string `json:"model"`
|
|
Choices []ChatChoice `json:"choices"`
|
|
Usage ChatUsage `json:"usage"`
|
|
Error *ChatError `json:"error,omitempty"`
|
|
}
|
|
|
|
// ChatChoice represents a single choice in the response
|
|
type ChatChoice struct {
|
|
Index int `json:"index"`
|
|
Message ChatMessage `json:"message"`
|
|
FinishReason string `json:"finish_reason"`
|
|
}
|
|
|
|
// ChatUsage represents token usage in the response
|
|
type ChatUsage struct {
|
|
PromptTokens int `json:"prompt_tokens"`
|
|
CompletionTokens int `json:"completion_tokens"`
|
|
TotalTokens int `json:"total_tokens"`
|
|
}
|
|
|
|
// ChatError represents an error in the API response
|
|
type ChatError struct {
|
|
Message string `json:"message"`
|
|
Type string `json:"type"`
|
|
Code string `json:"code"`
|
|
}
|
|
|
|
// EmbeddingRequest represents a request for embeddings
|
|
type EmbeddingRequest struct {
|
|
Model string `json:"model"`
|
|
Input []string `json:"input"`
|
|
EncodingFormat string `json:"encoding_format,omitempty"`
|
|
}
|
|
|
|
// EmbeddingResponse represents the response from embeddings API
|
|
type EmbeddingResponse struct {
|
|
Object string `json:"object"`
|
|
Data []EmbeddingData `json:"data"`
|
|
Model string `json:"model"`
|
|
Usage struct {
|
|
PromptTokens int `json:"prompt_tokens"`
|
|
TotalTokens int `json:"total_tokens"`
|
|
} `json:"usage"`
|
|
Error *ChatError `json:"error,omitempty"`
|
|
}
|
|
|
|
// EmbeddingData represents a single embedding in the response
|
|
type EmbeddingData struct {
|
|
Object string `json:"object"`
|
|
Embedding []float64 `json:"embedding"`
|
|
Index int `json:"index"`
|
|
}
|
|
|
|
// Execute executes an LLM step
|
|
func (e *LLMExecutor) Execute(ctx context.Context, step *core.Step, execCtx *core.ExecutionContext) (*core.StepResult, error) {
|
|
log := logger.Get()
|
|
result := &core.StepResult{
|
|
StepName: step.Name,
|
|
Status: core.StepStatusRunning,
|
|
StartTime: time.Now(),
|
|
Exports: make(map[string]interface{}),
|
|
}
|
|
|
|
// Validate config is set
|
|
if e.config == nil {
|
|
err := fmt.Errorf("LLM executor config not set")
|
|
result.Status = core.StepStatusFailed
|
|
result.Error = err
|
|
result.EndTime = time.Now()
|
|
result.Duration = result.EndTime.Sub(result.StartTime)
|
|
return result, err
|
|
}
|
|
|
|
// Get merged LLM configuration
|
|
llmConfig := e.getMergedConfig(step)
|
|
|
|
// Validate required fields
|
|
if len(step.Messages) == 0 && len(step.EmbeddingInput) == 0 {
|
|
err := fmt.Errorf("LLM step '%s' requires 'messages' or 'embedding_input' field", step.Name)
|
|
result.Status = core.StepStatusFailed
|
|
result.Error = err
|
|
result.EndTime = time.Now()
|
|
result.Duration = result.EndTime.Sub(result.StartTime)
|
|
return result, err
|
|
}
|
|
|
|
log.Debug("Executing LLM step",
|
|
zap.String("step", step.Name),
|
|
zap.Bool("is_embedding", step.IsEmbedding),
|
|
zap.Int("messages_count", len(step.Messages)),
|
|
)
|
|
|
|
// Handle embedding vs chat completion
|
|
if step.IsEmbedding || len(step.EmbeddingInput) > 0 {
|
|
return e.executeEmbedding(ctx, step, execCtx, result, llmConfig)
|
|
}
|
|
|
|
return e.executeChatCompletion(ctx, step, execCtx, result, llmConfig)
|
|
}
|
|
|
|
// executeChatCompletion executes a chat completion request with provider rotation
|
|
func (e *LLMExecutor) executeChatCompletion(
|
|
ctx context.Context,
|
|
step *core.Step,
|
|
execCtx *core.ExecutionContext,
|
|
result *core.StepResult,
|
|
llmConfig *MergedLLMConfig,
|
|
) (*core.StepResult, error) {
|
|
log := logger.Get()
|
|
|
|
// Build request
|
|
request, err := e.buildChatRequest(step, llmConfig)
|
|
if err != nil {
|
|
result.Status = core.StepStatusFailed
|
|
result.Error = err
|
|
result.EndTime = time.Now()
|
|
result.Duration = result.EndTime.Sub(result.StartTime)
|
|
return result, err
|
|
}
|
|
|
|
// Execute with retry and provider rotation
|
|
var response *ChatCompletionResponse
|
|
var lastErr error
|
|
|
|
maxRetries := llmConfig.MaxRetries
|
|
if maxRetries <= 0 {
|
|
maxRetries = 3
|
|
}
|
|
providerCount := e.config.LLM.GetProviderCount()
|
|
if providerCount == 0 {
|
|
err := fmt.Errorf("no LLM providers configured")
|
|
result.Status = core.StepStatusFailed
|
|
result.Error = err
|
|
result.EndTime = time.Now()
|
|
result.Duration = result.EndTime.Sub(result.StartTime)
|
|
return result, err
|
|
}
|
|
|
|
totalAttempts := maxRetries * providerCount
|
|
|
|
for attempt := 0; attempt < totalAttempts; attempt++ {
|
|
provider := e.config.LLM.GetCurrentProvider()
|
|
if provider == nil {
|
|
lastErr = fmt.Errorf("no LLM providers available")
|
|
break
|
|
}
|
|
|
|
// Update model from provider if not overridden
|
|
if llmConfig.Model == "" {
|
|
request.Model = provider.Model
|
|
}
|
|
|
|
log.Debug("Attempting LLM request",
|
|
zap.String("provider", provider.Provider),
|
|
zap.String("model", request.Model),
|
|
zap.Int("attempt", attempt+1),
|
|
zap.Int("max_attempts", totalAttempts),
|
|
)
|
|
|
|
response, lastErr = e.sendChatRequest(ctx, provider, request, llmConfig)
|
|
|
|
if lastErr == nil && response.Error == nil {
|
|
break // Success
|
|
}
|
|
|
|
// Check if we should rotate provider
|
|
if isProviderError(lastErr) || isRateLimitError(response) {
|
|
// Record rate limit hit for metrics
|
|
if isRateLimitError(response) {
|
|
metrics.RecordRateLimitHit(provider.Provider, "llm")
|
|
}
|
|
log.Warn("Provider error, rotating",
|
|
zap.String("provider", provider.Provider),
|
|
zap.Error(lastErr),
|
|
)
|
|
e.config.LLM.RotateProvider()
|
|
}
|
|
|
|
// Small backoff before retry
|
|
if attempt < totalAttempts-1 {
|
|
select {
|
|
case <-ctx.Done():
|
|
result.Status = core.StepStatusFailed
|
|
result.Error = ctx.Err()
|
|
result.EndTime = time.Now()
|
|
result.Duration = result.EndTime.Sub(result.StartTime)
|
|
return result, ctx.Err()
|
|
case <-time.After(time.Duration(attempt+1) * 500 * time.Millisecond):
|
|
}
|
|
}
|
|
}
|
|
|
|
if lastErr != nil {
|
|
result.Status = core.StepStatusFailed
|
|
result.Error = lastErr
|
|
result.EndTime = time.Now()
|
|
result.Duration = result.EndTime.Sub(result.StartTime)
|
|
return result, lastErr
|
|
}
|
|
|
|
if response != nil && response.Error != nil {
|
|
err := fmt.Errorf("LLM API error: %s (%s)", response.Error.Message, response.Error.Type)
|
|
result.Status = core.StepStatusFailed
|
|
result.Error = err
|
|
result.EndTime = time.Now()
|
|
result.Duration = result.EndTime.Sub(result.StartTime)
|
|
return result, err
|
|
}
|
|
|
|
// Process response and exports
|
|
e.processChatResponse(result, step.Name, response)
|
|
|
|
result.Status = core.StepStatusSuccess
|
|
result.EndTime = time.Now()
|
|
result.Duration = result.EndTime.Sub(result.StartTime)
|
|
|
|
return result, nil
|
|
}
|
|
|
|
// executeEmbedding executes an embedding request
|
|
func (e *LLMExecutor) executeEmbedding(
|
|
ctx context.Context,
|
|
step *core.Step,
|
|
execCtx *core.ExecutionContext,
|
|
result *core.StepResult,
|
|
llmConfig *MergedLLMConfig,
|
|
) (*core.StepResult, error) {
|
|
log := logger.Get()
|
|
|
|
if len(step.EmbeddingInput) == 0 {
|
|
err := fmt.Errorf("embedding step '%s' requires 'embedding_input' field", step.Name)
|
|
result.Status = core.StepStatusFailed
|
|
result.Error = err
|
|
result.EndTime = time.Now()
|
|
result.Duration = result.EndTime.Sub(result.StartTime)
|
|
return result, err
|
|
}
|
|
|
|
// Build embedding request
|
|
request := &EmbeddingRequest{
|
|
Model: llmConfig.Model,
|
|
Input: step.EmbeddingInput,
|
|
}
|
|
|
|
// Execute with retry and provider rotation
|
|
var response *EmbeddingResponse
|
|
var lastErr error
|
|
|
|
maxRetries := llmConfig.MaxRetries
|
|
if maxRetries <= 0 {
|
|
maxRetries = 3
|
|
}
|
|
providerCount := e.config.LLM.GetProviderCount()
|
|
if providerCount == 0 {
|
|
err := fmt.Errorf("no LLM providers configured")
|
|
result.Status = core.StepStatusFailed
|
|
result.Error = err
|
|
result.EndTime = time.Now()
|
|
result.Duration = result.EndTime.Sub(result.StartTime)
|
|
return result, err
|
|
}
|
|
|
|
totalAttempts := maxRetries * providerCount
|
|
|
|
for attempt := 0; attempt < totalAttempts; attempt++ {
|
|
provider := e.config.LLM.GetCurrentProvider()
|
|
if provider == nil {
|
|
lastErr = fmt.Errorf("no LLM providers available")
|
|
break
|
|
}
|
|
|
|
// Update model from provider if not overridden
|
|
if request.Model == "" {
|
|
request.Model = provider.Model
|
|
}
|
|
|
|
log.Debug("Attempting embedding request",
|
|
zap.String("provider", provider.Provider),
|
|
zap.String("model", request.Model),
|
|
zap.Int("attempt", attempt+1),
|
|
)
|
|
|
|
response, lastErr = e.sendEmbeddingRequest(ctx, provider, request, llmConfig)
|
|
|
|
if lastErr == nil && response.Error == nil {
|
|
break // Success
|
|
}
|
|
|
|
// Check if we should rotate provider
|
|
if isProviderError(lastErr) || (response != nil && response.Error != nil) {
|
|
log.Warn("Provider error, rotating",
|
|
zap.String("provider", provider.Provider),
|
|
zap.Error(lastErr),
|
|
)
|
|
e.config.LLM.RotateProvider()
|
|
}
|
|
}
|
|
|
|
if lastErr != nil {
|
|
result.Status = core.StepStatusFailed
|
|
result.Error = lastErr
|
|
result.EndTime = time.Now()
|
|
result.Duration = result.EndTime.Sub(result.StartTime)
|
|
return result, lastErr
|
|
}
|
|
|
|
if response != nil && response.Error != nil {
|
|
err := fmt.Errorf("embedding API error: %s (%s)", response.Error.Message, response.Error.Type)
|
|
result.Status = core.StepStatusFailed
|
|
result.Error = err
|
|
result.EndTime = time.Now()
|
|
result.Duration = result.EndTime.Sub(result.StartTime)
|
|
return result, err
|
|
}
|
|
|
|
// Process embedding response
|
|
e.processEmbeddingResponse(result, step.Name, response)
|
|
|
|
result.Status = core.StepStatusSuccess
|
|
result.EndTime = time.Now()
|
|
result.Duration = result.EndTime.Sub(result.StartTime)
|
|
|
|
return result, nil
|
|
}
|
|
|
|
// buildChatRequest builds an OpenAI-compatible chat request
|
|
func (e *LLMExecutor) buildChatRequest(step *core.Step, llmConfig *MergedLLMConfig) (*ChatCompletionRequest, error) {
|
|
request := &ChatCompletionRequest{
|
|
Model: llmConfig.Model,
|
|
MaxTokens: llmConfig.MaxTokens,
|
|
Temperature: llmConfig.Temperature,
|
|
TopP: llmConfig.TopP,
|
|
TopK: llmConfig.TopK,
|
|
N: llmConfig.N,
|
|
Stream: llmConfig.Stream,
|
|
}
|
|
|
|
// Convert messages
|
|
messages := make([]ChatMessage, 0, len(step.Messages)+1)
|
|
|
|
// Auto-prepend system prompt if global one exists and step doesn't have one
|
|
if llmConfig.SystemPrompt != "" {
|
|
hasSystemMessage := false
|
|
for _, msg := range step.Messages {
|
|
if msg.Role == core.LLMRoleSystem {
|
|
hasSystemMessage = true
|
|
break
|
|
}
|
|
}
|
|
if !hasSystemMessage {
|
|
messages = append(messages, ChatMessage{
|
|
Role: string(core.LLMRoleSystem),
|
|
Content: llmConfig.SystemPrompt,
|
|
})
|
|
}
|
|
}
|
|
|
|
// Add step messages
|
|
for _, msg := range step.Messages {
|
|
chatMsg := ChatMessage{
|
|
Role: string(msg.Role),
|
|
Content: msg.Content,
|
|
Name: msg.Name,
|
|
ToolCallID: msg.ToolCallID,
|
|
ToolCalls: msg.ToolCalls,
|
|
}
|
|
messages = append(messages, chatMsg)
|
|
}
|
|
|
|
request.Messages = messages
|
|
|
|
// Add tools if specified
|
|
if len(step.Tools) > 0 {
|
|
request.Tools = step.Tools
|
|
}
|
|
|
|
// Add tool choice if specified
|
|
if step.ToolChoice != nil {
|
|
request.ToolChoice = step.ToolChoice
|
|
}
|
|
|
|
// Add response format if specified
|
|
if llmConfig.ResponseFormat != nil {
|
|
request.ResponseFormat = llmConfig.ResponseFormat
|
|
}
|
|
|
|
return request, nil
|
|
}
|
|
|
|
// sendChatRequest sends an HTTP request to the LLM provider
|
|
func (e *LLMExecutor) sendChatRequest(
|
|
ctx context.Context,
|
|
provider *config.LLMProvider,
|
|
request *ChatCompletionRequest,
|
|
llmConfig *MergedLLMConfig,
|
|
) (*ChatCompletionResponse, error) {
|
|
// Marshal request to JSON
|
|
body, err := json.Marshal(request)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to marshal request: %w", err)
|
|
}
|
|
|
|
// Create HTTP request
|
|
req, err := http.NewRequestWithContext(ctx, "POST", provider.BaseURL, bytes.NewReader(body))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create request: %w", err)
|
|
}
|
|
|
|
// Set headers
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
if provider.AuthToken != "" {
|
|
req.Header.Set("Authorization", "Bearer "+provider.AuthToken)
|
|
}
|
|
|
|
// Add custom headers
|
|
for key, value := range llmConfig.CustomHeaders {
|
|
req.Header.Set(key, value)
|
|
}
|
|
|
|
// Set timeout
|
|
timeout, err := time.ParseDuration(llmConfig.Timeout)
|
|
if err != nil {
|
|
timeout = 120 * time.Second
|
|
}
|
|
client := &http.Client{Timeout: timeout}
|
|
|
|
// Execute request
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("request failed: %w", err)
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
// Read response
|
|
respBody, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to read response: %w", err)
|
|
}
|
|
|
|
// Parse response
|
|
var response ChatCompletionResponse
|
|
if err := json.Unmarshal(respBody, &response); err != nil {
|
|
return nil, fmt.Errorf("failed to parse response: %w (body: %s)", err, string(respBody))
|
|
}
|
|
|
|
// Check for HTTP errors
|
|
if resp.StatusCode >= 400 {
|
|
if response.Error != nil {
|
|
return &response, fmt.Errorf("HTTP %d: %s", resp.StatusCode, response.Error.Message)
|
|
}
|
|
return &response, fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(respBody))
|
|
}
|
|
|
|
return &response, nil
|
|
}
|
|
|
|
// sendEmbeddingRequest sends an embedding request to the LLM provider
|
|
func (e *LLMExecutor) sendEmbeddingRequest(
|
|
ctx context.Context,
|
|
provider *config.LLMProvider,
|
|
request *EmbeddingRequest,
|
|
llmConfig *MergedLLMConfig,
|
|
) (*EmbeddingResponse, error) {
|
|
// Marshal request to JSON
|
|
body, err := json.Marshal(request)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to marshal request: %w", err)
|
|
}
|
|
|
|
// Determine embedding endpoint - typically /v1/embeddings
|
|
embeddingURL := provider.BaseURL
|
|
if strings.HasSuffix(embeddingURL, "/chat/completions") {
|
|
embeddingURL = strings.Replace(embeddingURL, "/chat/completions", "/embeddings", 1)
|
|
}
|
|
|
|
// Create HTTP request
|
|
req, err := http.NewRequestWithContext(ctx, "POST", embeddingURL, bytes.NewReader(body))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create request: %w", err)
|
|
}
|
|
|
|
// Set headers
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
if provider.AuthToken != "" {
|
|
req.Header.Set("Authorization", "Bearer "+provider.AuthToken)
|
|
}
|
|
|
|
// Add custom headers
|
|
for key, value := range llmConfig.CustomHeaders {
|
|
req.Header.Set(key, value)
|
|
}
|
|
|
|
// Set timeout
|
|
timeout, err := time.ParseDuration(llmConfig.Timeout)
|
|
if err != nil {
|
|
timeout = 120 * time.Second
|
|
}
|
|
client := &http.Client{Timeout: timeout}
|
|
|
|
// Execute request
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("request failed: %w", err)
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
// Read response
|
|
respBody, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to read response: %w", err)
|
|
}
|
|
|
|
// Parse response
|
|
var response EmbeddingResponse
|
|
if err := json.Unmarshal(respBody, &response); err != nil {
|
|
return nil, fmt.Errorf("failed to parse response: %w (body: %s)", err, string(respBody))
|
|
}
|
|
|
|
// Check for HTTP errors
|
|
if resp.StatusCode >= 400 {
|
|
if response.Error != nil {
|
|
return &response, fmt.Errorf("HTTP %d: %s", resp.StatusCode, response.Error.Message)
|
|
}
|
|
return &response, fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(respBody))
|
|
}
|
|
|
|
return &response, nil
|
|
}
|
|
|
|
// printLLMOutput prints LLM response with glamour markdown rendering
|
|
func printLLMOutput(content string) {
|
|
// Render with glamour for markdown highlighting
|
|
renderer, err := glamour.NewTermRenderer(
|
|
glamour.WithAutoStyle(),
|
|
glamour.WithWordWrap(120),
|
|
)
|
|
|
|
var rendered string
|
|
if err == nil {
|
|
if out, renderErr := renderer.Render(content); renderErr == nil {
|
|
rendered = out
|
|
} else {
|
|
rendered = content + "\n"
|
|
}
|
|
} else {
|
|
rendered = content + "\n"
|
|
}
|
|
|
|
fmt.Print(rendered)
|
|
}
|
|
|
|
// processChatResponse exports the LLM response to step result
|
|
func (e *LLMExecutor) processChatResponse(result *core.StepResult, stepName string, response *ChatCompletionResponse) {
|
|
exportKey := sanitizeStepName(stepName) + "_llm_resp"
|
|
|
|
// Build comprehensive export structure
|
|
llmResp := map[string]interface{}{
|
|
"id": response.ID,
|
|
"model": response.Model,
|
|
"created": response.Created,
|
|
"usage": map[string]interface{}{
|
|
"prompt_tokens": response.Usage.PromptTokens,
|
|
"completion_tokens": response.Usage.CompletionTokens,
|
|
"total_tokens": response.Usage.TotalTokens,
|
|
},
|
|
}
|
|
|
|
// Export choices
|
|
if len(response.Choices) > 0 {
|
|
choice := response.Choices[0]
|
|
llmResp["content"] = choice.Message.Content
|
|
llmResp["finish_reason"] = choice.FinishReason
|
|
llmResp["role"] = choice.Message.Role
|
|
|
|
// Export tool calls if present
|
|
if len(choice.Message.ToolCalls) > 0 {
|
|
llmResp["tool_calls"] = choice.Message.ToolCalls
|
|
}
|
|
|
|
// Set output to content for display
|
|
if content, ok := choice.Message.Content.(string); ok {
|
|
result.Output = content
|
|
// Print LLM output with symbol prefix and markdown formatting (skip in silent mode)
|
|
if !e.silent {
|
|
printLLMOutput(content)
|
|
}
|
|
}
|
|
}
|
|
|
|
// All choices for n > 1
|
|
if len(response.Choices) > 1 {
|
|
allContents := make([]interface{}, len(response.Choices))
|
|
for i, c := range response.Choices {
|
|
allContents[i] = c.Message.Content
|
|
}
|
|
llmResp["all_contents"] = allContents
|
|
}
|
|
|
|
result.Exports[exportKey] = llmResp
|
|
|
|
// Also export content directly for easy access
|
|
if len(response.Choices) > 0 {
|
|
contentKey := sanitizeStepName(stepName) + "_content"
|
|
result.Exports[contentKey] = response.Choices[0].Message.Content
|
|
}
|
|
}
|
|
|
|
// processEmbeddingResponse exports the embedding response to step result
|
|
func (e *LLMExecutor) processEmbeddingResponse(result *core.StepResult, stepName string, response *EmbeddingResponse) {
|
|
exportKey := sanitizeStepName(stepName) + "_llm_resp"
|
|
|
|
// Build export structure
|
|
llmResp := map[string]interface{}{
|
|
"model": response.Model,
|
|
"usage": map[string]interface{}{
|
|
"prompt_tokens": response.Usage.PromptTokens,
|
|
"total_tokens": response.Usage.TotalTokens,
|
|
},
|
|
}
|
|
|
|
// Export embeddings
|
|
if len(response.Data) > 0 {
|
|
embeddings := make([][]float64, len(response.Data))
|
|
for i, d := range response.Data {
|
|
embeddings[i] = d.Embedding
|
|
}
|
|
llmResp["embeddings"] = embeddings
|
|
|
|
// Set output to summary
|
|
result.Output = fmt.Sprintf("Generated %d embeddings", len(embeddings))
|
|
}
|
|
|
|
result.Exports[exportKey] = llmResp
|
|
}
|
|
|
|
// getMergedConfig merges global llm_config with step-level overrides
|
|
func (e *LLMExecutor) getMergedConfig(step *core.Step) *MergedLLMConfig {
|
|
globalLLM := &e.config.LLM
|
|
|
|
merged := &MergedLLMConfig{
|
|
MaxTokens: globalLLM.MaxTokens,
|
|
Temperature: globalLLM.Temperature,
|
|
TopK: globalLLM.TopK,
|
|
TopP: globalLLM.TopP,
|
|
N: globalLLM.N,
|
|
Timeout: globalLLM.Timeout,
|
|
MaxRetries: globalLLM.MaxRetries,
|
|
Stream: globalLLM.Stream,
|
|
SystemPrompt: globalLLM.SystemPrompt,
|
|
CustomHeaders: make(map[string]string),
|
|
}
|
|
|
|
// Set default response format if structured JSON is enabled globally
|
|
if globalLLM.StructuredJSONFormat {
|
|
merged.ResponseFormat = &core.LLMResponseFormat{
|
|
Type: "json_object",
|
|
}
|
|
}
|
|
|
|
// Parse global custom headers (format: "Key1: Value1, Key2: Value2")
|
|
if globalLLM.CustomHeaders != "" {
|
|
for _, h := range strings.Split(globalLLM.CustomHeaders, ",") {
|
|
if parts := strings.SplitN(strings.TrimSpace(h), ":", 2); len(parts) == 2 {
|
|
merged.CustomHeaders[strings.TrimSpace(parts[0])] = strings.TrimSpace(parts[1])
|
|
}
|
|
}
|
|
}
|
|
|
|
// Apply step-level overrides
|
|
if step.LLMConfig != nil {
|
|
cfg := step.LLMConfig
|
|
|
|
if cfg.Model != "" {
|
|
merged.Model = cfg.Model
|
|
}
|
|
if cfg.MaxTokens != nil {
|
|
merged.MaxTokens = *cfg.MaxTokens
|
|
}
|
|
if cfg.Temperature != nil {
|
|
merged.Temperature = *cfg.Temperature
|
|
}
|
|
if cfg.TopK != nil {
|
|
merged.TopK = *cfg.TopK
|
|
}
|
|
if cfg.TopP != nil {
|
|
merged.TopP = *cfg.TopP
|
|
}
|
|
if cfg.N != nil {
|
|
merged.N = *cfg.N
|
|
}
|
|
if cfg.Timeout != "" {
|
|
merged.Timeout = cfg.Timeout
|
|
}
|
|
if cfg.MaxRetries != nil {
|
|
merged.MaxRetries = *cfg.MaxRetries
|
|
}
|
|
if cfg.Stream != nil {
|
|
merged.Stream = *cfg.Stream
|
|
}
|
|
if cfg.ResponseFormat != nil {
|
|
merged.ResponseFormat = cfg.ResponseFormat
|
|
}
|
|
|
|
// Merge custom headers (step overrides global)
|
|
for k, v := range cfg.CustomHeaders {
|
|
merged.CustomHeaders[k] = v
|
|
}
|
|
}
|
|
|
|
// Apply extra LLM parameters (these can override anything)
|
|
if step.ExtraLLMParams != nil {
|
|
if model, ok := step.ExtraLLMParams["model"].(string); ok {
|
|
merged.Model = model
|
|
}
|
|
if maxTokens, ok := step.ExtraLLMParams["max_tokens"].(int); ok {
|
|
merged.MaxTokens = maxTokens
|
|
}
|
|
if temp, ok := step.ExtraLLMParams["temperature"].(float64); ok {
|
|
merged.Temperature = temp
|
|
}
|
|
if topK, ok := step.ExtraLLMParams["top_k"].(int); ok {
|
|
merged.TopK = topK
|
|
}
|
|
if topP, ok := step.ExtraLLMParams["top_p"].(float64); ok {
|
|
merged.TopP = topP
|
|
}
|
|
}
|
|
|
|
return merged
|
|
}
|
|
|
|
// isProviderError checks if error indicates provider-level failure
|
|
func isProviderError(err error) bool {
|
|
if err == nil {
|
|
return false
|
|
}
|
|
errStr := err.Error()
|
|
return strings.Contains(errStr, "connection refused") ||
|
|
strings.Contains(errStr, "no such host") ||
|
|
strings.Contains(errStr, "timeout") ||
|
|
strings.Contains(errStr, "EOF") ||
|
|
strings.Contains(errStr, "i/o timeout")
|
|
}
|
|
|
|
// isRateLimitError checks if response indicates rate limiting
|
|
func isRateLimitError(resp *ChatCompletionResponse) bool {
|
|
if resp == nil || resp.Error == nil {
|
|
return false
|
|
}
|
|
return resp.Error.Type == "rate_limit_error" ||
|
|
strings.Contains(resp.Error.Code, "rate_limit") ||
|
|
strings.Contains(resp.Error.Message, "rate limit") ||
|
|
strings.Contains(resp.Error.Message, "Rate limit")
|
|
}
|