llm_conductor 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'tiktoken_ruby'
4
+ require 'ollama-ai'
5
+ require 'openai'
6
+
7
+ module LlmConductor
8
+ module Clients
9
+ # Base client class providing common functionality for all LLM providers
10
+ # including prompt building, token counting, and response formatting.
11
+ class BaseClient
12
+ include Prompts
13
+
14
+ attr_reader :model, :type
15
+
16
+ def initialize(model:, type:)
17
+ @model = model
18
+ @type = type
19
+ end
20
+
21
+ def generate(data:)
22
+ prompt = build_prompt(data)
23
+ input_tokens = calculate_tokens(prompt)
24
+ output_text = generate_content(prompt)
25
+ output_tokens = calculate_tokens(output_text || '')
26
+
27
+ build_response(output_text, input_tokens, output_tokens, { prompt: })
28
+ rescue StandardError => e
29
+ build_error_response(e)
30
+ end
31
+
32
+ # Simple generation method that accepts a direct prompt and returns a Response object
33
+ def generate_simple(prompt:)
34
+ input_tokens = calculate_tokens(prompt)
35
+ output_text = generate_content(prompt)
36
+ output_tokens = calculate_tokens(output_text || '')
37
+
38
+ build_response(output_text, input_tokens, output_tokens)
39
+ rescue StandardError => e
40
+ build_error_response(e)
41
+ end
42
+
43
+ private
44
+
45
+ def build_response(output_text, input_tokens, output_tokens, additional_metadata = {})
46
+ Response.new(
47
+ output: output_text,
48
+ model:,
49
+ input_tokens:,
50
+ output_tokens:,
51
+ metadata: build_metadata.merge(additional_metadata)
52
+ )
53
+ end
54
+
55
+ def build_error_response(error)
56
+ Response.new(
57
+ output: nil,
58
+ model:,
59
+ metadata: { error: error.message, error_class: error.class.name }
60
+ )
61
+ end
62
+
63
+ def build_prompt(data)
64
+ # Check if this is a registered prompt type
65
+ if PromptManager.registered?(type)
66
+ PromptManager.render(type, data)
67
+ else
68
+ # Fallback to legacy prompt methods
69
+ send(:"prompt_#{type}", data)
70
+ end
71
+ end
72
+
73
+ def generate_content(prompt)
74
+ raise NotImplementedError
75
+ end
76
+
77
+ def calculate_tokens(content)
78
+ encoder.encode(content).length
79
+ end
80
+
81
+ def encoder
82
+ @encoder ||= Tiktoken.get_encoding('cl100k_base')
83
+ end
84
+
85
+ def client
86
+ raise NotImplementedError
87
+ end
88
+
89
+ # Build metadata for the response
90
+ def build_metadata
91
+ {
92
+ vendor: self.class.name.split('::').last.gsub('Client', '').downcase.to_sym,
93
+ timestamp: Time.zone.now.iso8601
94
+ }
95
+ end
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LlmConductor
4
+ module Clients
5
+ # OpenAI GPT client implementation for accessing GPT models via OpenAI API
6
+ class GptClient < BaseClient
7
+ private
8
+
9
+ def generate_content(prompt)
10
+ client.chat(parameters: { model:, messages: [{ role: 'user', content: prompt }] })
11
+ .dig('choices', 0, 'message', 'content')
12
+ end
13
+
14
+ def client
15
+ @client ||= begin
16
+ config = LlmConductor.configuration.provider_config(:openai)
17
+ options = { access_token: config[:api_key] }
18
+ options[:organization_id] = config[:organization] if config[:organization]
19
+ OpenAI::Client.new(options)
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LlmConductor
4
+ module Clients
5
+ # Ollama client implementation for accessing local or self-hosted Ollama models
6
+ class OllamaClient < BaseClient
7
+ private
8
+
9
+ def generate_content(prompt)
10
+ client.generate({ model:, prompt:, stream: false }).first['response']
11
+ end
12
+
13
+ def client
14
+ @client ||= begin
15
+ config = LlmConductor.configuration.provider_config(:ollama)
16
+ Ollama.new(
17
+ credentials: { address: config[:base_url] },
18
+ options: { server_sent_events: true }
19
+ )
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LlmConductor
4
+ module Clients
5
+ # OpenRouter client implementation for accessing various LLM providers through OpenRouter API
6
+ class OpenrouterClient < BaseClient
7
+ private
8
+
9
+ def generate_content(prompt)
10
+ client.chat(
11
+ parameters: {
12
+ model:,
13
+ messages: [{ role: 'user', content: prompt }],
14
+ provider: { sort: 'throughput' }
15
+ }
16
+ ).dig('choices', 0, 'message', 'content')
17
+ end
18
+
19
+ def client
20
+ @client ||= begin
21
+ config = LlmConductor.configuration.provider_config(:openrouter)
22
+ OpenAI::Client.new(
23
+ access_token: config[:api_key],
24
+ uri_base: config[:uri_base] || 'https://openrouter.ai/api/'
25
+ )
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ # LLM Conductor provides a unified interface for multiple Language Model providers
4
+ module LlmConductor
5
+ # Configuration class for managing API keys, endpoints, and default settings
6
+ class Configuration
7
+ attr_accessor :default_model, :default_vendor, :timeout, :max_retries, :retry_delay
8
+ attr_reader :providers
9
+
10
+ def initialize
11
+ # Default settings
12
+ @default_model = 'gpt-5-mini'
13
+ @default_vendor = :openai
14
+ @timeout = 30
15
+ @max_retries = 3
16
+ @retry_delay = 1.0
17
+
18
+ # Provider configurations
19
+ @providers = {}
20
+
21
+ # Initialize with environment variables if available
22
+ setup_defaults_from_env
23
+ end
24
+
25
+ # Configure OpenAI provider
26
+ def openai(api_key: nil, organization: nil, **options)
27
+ @providers[:openai] = {
28
+ api_key: api_key || ENV['OPENAI_API_KEY'],
29
+ organization: organization || ENV['OPENAI_ORG_ID'],
30
+ **options
31
+ }
32
+ end
33
+
34
+ # Configure Ollama provider
35
+ def ollama(base_url: nil, **options)
36
+ @providers[:ollama] = {
37
+ base_url: base_url || ENV['OLLAMA_ADDRESS'] || 'http://localhost:11434',
38
+ **options
39
+ }
40
+ end
41
+
42
+ # Configure OpenRouter provider
43
+ def openrouter(api_key: nil, **options)
44
+ @providers[:openrouter] = {
45
+ api_key: api_key || ENV['OPENROUTER_API_KEY'],
46
+ **options
47
+ }
48
+ end
49
+
50
+ # Get provider configuration
51
+ def provider_config(provider)
52
+ @providers[provider.to_sym] || {}
53
+ end
54
+
55
+ # Legacy compatibility methods
56
+ def openai_api_key
57
+ provider_config(:openai)[:api_key]
58
+ end
59
+
60
+ def openai_api_key=(value)
61
+ openai(api_key: value)
62
+ end
63
+
64
+ def openrouter_api_key
65
+ provider_config(:openrouter)[:api_key]
66
+ end
67
+
68
+ def openrouter_api_key=(value)
69
+ openrouter(api_key: value)
70
+ end
71
+
72
+ def ollama_address
73
+ provider_config(:ollama)[:base_url]
74
+ end
75
+
76
+ def ollama_address=(value)
77
+ ollama(base_url: value)
78
+ end
79
+
80
+ private
81
+
82
+ def setup_defaults_from_env
83
+ # Auto-configure providers if environment variables are present
84
+ openai if ENV['OPENAI_API_KEY']
85
+ openrouter if ENV['OPENROUTER_API_KEY']
86
+ ollama # Always configure Ollama with default URL
87
+ end
88
+ end
89
+
90
+ def self.configuration
91
+ @configuration ||= Configuration.new
92
+ end
93
+
94
+ def self.configure
95
+ yield(configuration)
96
+ end
97
+ end
@@ -0,0 +1,211 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LlmConductor
4
+ # Base class for building structured data from source objects for LLM consumption.
5
+ # Provides helper methods for data extraction, formatting, and safe handling of nested data.
6
+ #
7
+ # @example Basic usage
8
+ # class CompanyDataBuilder < LlmConductor::DataBuilder
9
+ # def build
10
+ # {
11
+ # id: source_object.id,
12
+ # name: source_object.name,
13
+ # metrics: build_metrics
14
+ # }
15
+ # end
16
+ #
17
+ # private
18
+ #
19
+ # def build_metrics
20
+ # {
21
+ # employees: safe_extract(:employee_count, default: 'Unknown')
22
+ # }
23
+ # end
24
+ # end
25
+ #
26
+ # builder = CompanyDataBuilder.new(company)
27
+ # data = builder.build
28
+ class DataBuilder
29
+ attr_reader :source_object
30
+
31
+ def initialize(source_object)
32
+ @source_object = source_object
33
+ end
34
+
35
+ # Abstract method to be implemented by subclasses
36
+ # @return [Hash] The built data structure
37
+ def build
38
+ raise NotImplementedError, "#{self.class} must implement the #build method"
39
+ end
40
+
41
+ protected
42
+
43
+ # Safely extract a value from the source object with a default fallback
44
+ # @param attribute [Symbol, String] The attribute name to extract
45
+ # @param default [Object] Default value if extraction fails
46
+ # @return [Object] The extracted value or default
47
+ def safe_extract(attribute, default: nil)
48
+ return default if source_object.nil?
49
+
50
+ if source_object.respond_to?(attribute)
51
+ value = source_object.public_send(attribute)
52
+ value.nil? || (value.respond_to?(:empty?) && value.empty?) ? default : value
53
+ else
54
+ default
55
+ end
56
+ end
57
+
58
+ # Extract nested data from a hash-like structure
59
+ # @param root_key [Symbol, String] The root key to start extraction from
60
+ # @param *path [Array<String, Symbol>] The path to navigate through nested structures
61
+ # @return [Object, nil] The extracted value or nil if not found
62
+ def extract_nested_data(root_key, *path)
63
+ return nil if source_object.nil?
64
+
65
+ data = safe_extract(root_key)
66
+ return nil unless hash_like?(data)
67
+
68
+ navigate_nested_path(data, path)
69
+ rescue StandardError
70
+ nil
71
+ end
72
+
73
+ # Format a value for LLM consumption with appropriate fallbacks
74
+ # @param value [Object] The value to format
75
+ # @param options [Hash] Formatting options
76
+ # @option options [String] :default ('N/A') Default value for nil/empty values
77
+ # @option options [String] :prefix ('') Prefix to add to non-empty values
78
+ # @option options [String] :suffix ('') Suffix to add to non-empty values
79
+ # @option options [Integer] :max_length (nil) Maximum length to truncate to
80
+ # @return [String] Formatted value suitable for LLM consumption
81
+ def format_for_llm(value, options = {})
82
+ default = options.fetch(:default, 'N/A')
83
+ prefix = options.fetch(:prefix, '')
84
+ suffix = options.fetch(:suffix, '')
85
+ max_length = options[:max_length]
86
+
87
+ # Handle nil or empty values
88
+ return default if value.nil? || (value.respond_to?(:empty?) && value.empty?)
89
+
90
+ # Convert to string and apply formatting
91
+ formatted = "#{prefix}#{value}#{suffix}"
92
+
93
+ # Apply length limit if specified
94
+ formatted = "#{formatted[0...max_length]}..." if max_length && formatted.length > max_length
95
+
96
+ formatted
97
+ end
98
+
99
+ # Extract and format a list of items
100
+ # @param attribute [Symbol, String] The attribute containing the list
101
+ # @param options [Hash] Formatting options
102
+ # @option options [String] :separator (', ') Separator for joining items
103
+ # @option options [Integer] :limit (nil) Maximum number of items to include
104
+ # @option options [String] :default ('None') Default value for empty lists
105
+ # @return [String] Formatted list suitable for LLM consumption
106
+ def extract_list(attribute, options = {})
107
+ separator = options.fetch(:separator, ', ')
108
+ limit = options[:limit]
109
+ default = options.fetch(:default, 'None')
110
+
111
+ items = safe_extract(attribute, default: [])
112
+ return default unless items.respond_to?(:map) && !items.empty?
113
+
114
+ # Apply limit if specified
115
+ items = items.first(limit) if limit
116
+
117
+ items.map(&:to_s).join(separator)
118
+ end
119
+
120
+ # Build a summary string from multiple attributes
121
+ # @param attributes [Array<Symbol>] List of attributes to include
122
+ # @param options [Hash] Formatting options
123
+ # @option options [String] :separator (' | ') Separator between attributes
124
+ # @option options [Boolean] :skip_empty (true) Whether to skip empty values
125
+ # @return [String] Combined summary string
126
+ def build_summary(*attributes, **options)
127
+ separator = options.fetch(:separator, ' | ')
128
+ skip_empty = options.fetch(:skip_empty, true)
129
+
130
+ parts = attributes.map do |attr|
131
+ value = safe_extract(attr)
132
+ next if skip_empty && (value.nil? || (value.respond_to?(:empty?) && value.empty?))
133
+
134
+ format_for_llm(value)
135
+ end.compact
136
+
137
+ parts.join(separator)
138
+ end
139
+
140
+ # Helper method to safely convert numeric values with formatting
141
+ # @param value [Numeric, String] The value to format
142
+ # @param options [Hash] Formatting options
143
+ # @option options [String] :currency ('$') Currency symbol for monetary values
144
+ # @option options [Boolean] :as_currency (false) Whether to format as currency
145
+ # @option options [Integer] :precision (0) Decimal precision
146
+ # @return [String] Formatted numeric value
147
+ def format_number(value, options = {})
148
+ return format_for_llm(nil, options) if value.nil?
149
+
150
+ begin
151
+ numeric_value = Float(value)
152
+ formatted = format_numeric_value(numeric_value, options[:precision] || 0)
153
+ formatted = add_thousands_separators(formatted)
154
+
155
+ apply_currency_formatting(formatted, options)
156
+ rescue ArgumentError
157
+ format_for_llm(value, options)
158
+ end
159
+ end
160
+
161
+ private
162
+
163
+ # Helper to check if an object has a specific method
164
+ def attribute?(attribute)
165
+ source_object&.respond_to?(attribute)
166
+ end
167
+
168
+ # Check if data structure supports hash-like access
169
+ def hash_like?(data)
170
+ data.respond_to?(:[])
171
+ end
172
+
173
+ # Navigate through nested hash path
174
+ def navigate_nested_path(data, path)
175
+ path.reduce(data) do |current_data, key|
176
+ return nil unless hash_like?(current_data)
177
+
178
+ find_key_variant(current_data, key)
179
+ end
180
+ end
181
+
182
+ # Find key in various formats (string, symbol)
183
+ def find_key_variant(data, key)
184
+ data[key] || data[key.to_s] || data[key.to_sym]
185
+ end
186
+
187
+ # Format numeric value with precision
188
+ def format_numeric_value(numeric_value, precision)
189
+ if precision.positive?
190
+ format("%.#{precision}f", numeric_value)
191
+ else
192
+ numeric_value.to_i.to_s
193
+ end
194
+ end
195
+
196
+ # Add thousands separators to numeric string
197
+ def add_thousands_separators(formatted)
198
+ formatted.reverse.gsub(/(\d{3})(?=\d)/, '\\1,').reverse
199
+ end
200
+
201
+ # Apply currency formatting if requested
202
+ def apply_currency_formatting(formatted, options)
203
+ if options.fetch(:as_currency, false)
204
+ currency = options.fetch(:currency, '$')
205
+ "#{currency}#{formatted}"
206
+ else
207
+ formatted
208
+ end
209
+ end
210
+ end
211
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LlmConductor
4
+ # Manages registration and creation of prompt classes
5
+ class PromptManager
6
+ class PromptNotFoundError < StandardError; end
7
+ class InvalidPromptClassError < StandardError; end
8
+
9
+ @registry = {}
10
+
11
+ class << self
12
+ attr_reader :registry
13
+
14
+ # Register a prompt class with a given type
15
+ def register(type, prompt_class)
16
+ validate_prompt_class!(prompt_class)
17
+
18
+ @registry[type.to_sym] = prompt_class
19
+ end
20
+
21
+ # Unregister a prompt type (useful for testing)
22
+ def unregister(type)
23
+ @registry.delete(type.to_sym)
24
+ end
25
+
26
+ # Get a registered prompt class
27
+ def get(type)
28
+ @registry[type.to_sym] || raise(PromptNotFoundError, "Prompt type :#{type} not found")
29
+ end
30
+
31
+ # Check if a prompt type is registered
32
+ def registered?(type)
33
+ @registry.key?(type.to_sym)
34
+ end
35
+
36
+ # List all registered prompt types
37
+ def types
38
+ @registry.keys
39
+ end
40
+
41
+ # Clear all registrations (useful for testing)
42
+ def clear!
43
+ @registry.clear
44
+ end
45
+
46
+ # Create a prompt instance
47
+ def create(type, data = {})
48
+ prompt_class = get(type)
49
+ prompt_class.new(data)
50
+ end
51
+
52
+ # Create and render a prompt in one step
53
+ def render(type, data = {})
54
+ create(type, data).render
55
+ end
56
+
57
+ private
58
+
59
+ def validate_prompt_class!(prompt_class)
60
+ raise InvalidPromptClassError, 'Prompt must be a class' unless prompt_class.is_a?(Class)
61
+
62
+ unless prompt_class < Prompts::BasePrompt
63
+ raise InvalidPromptClassError, 'Prompt class must inherit from BasePrompt'
64
+ end
65
+
66
+ return if prompt_class.instance_methods(false).include?(:render)
67
+
68
+ raise InvalidPromptClassError, 'Prompt class must implement #render method'
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LlmConductor
4
+ module Prompts
5
+ # Base class for all prompt templates
6
+ # Provides common functionality and data access patterns
7
+ class BasePrompt
8
+ attr_reader :data
9
+
10
+ def initialize(data = {})
11
+ @data = data || {}
12
+ setup_data_methods
13
+ end
14
+
15
+ # Override this method in subclasses to define the prompt
16
+ def render
17
+ raise NotImplementedError, 'Subclasses must implement #render method'
18
+ end
19
+
20
+ # Get the rendered prompt as a string
21
+ def to_s
22
+ render.strip
23
+ end
24
+
25
+ # Safe access to nested hash values
26
+ def data_dig(*keys)
27
+ @data.dig(*keys) if @data.respond_to?(:dig)
28
+ end
29
+
30
+ # Convenience alias for data_dig
31
+ alias dig data_dig
32
+
33
+ protected
34
+
35
+ # Truncate text to a maximum length with ellipsis
36
+ def truncate_text(text, max_length: 100)
37
+ return '' if text.nil?
38
+
39
+ text = text.to_s
40
+ return text if text.length <= max_length
41
+
42
+ "#{text[0...max_length]}..."
43
+ end
44
+
45
+ # Format a list as a numbered list
46
+ def numbered_list(items)
47
+ return '' if items.nil? || items.empty?
48
+
49
+ items.map.with_index(1) { |item, index| "#{index}. #{item}" }.join("\n")
50
+ end
51
+
52
+ # Format a list as a bulleted list
53
+ def bulleted_list(items)
54
+ return '' if items.nil? || items.empty?
55
+
56
+ items.map { |item| "• #{item}" }.join("\n")
57
+ end
58
+
59
+ private
60
+
61
+ # Create dynamic method accessors for data keys
62
+ def setup_data_methods
63
+ return unless @data.respond_to?(:each)
64
+
65
+ @data.each do |key, value|
66
+ next unless key.respond_to?(:to_sym)
67
+
68
+ define_singleton_method(key.to_sym) { value }
69
+ end
70
+ end
71
+
72
+ # Handle missing methods by checking data hash
73
+ def method_missing(method_name, *args, &block)
74
+ if @data.respond_to?(:[]) && @data.key?(method_name)
75
+ @data[method_name]
76
+ elsif @data.respond_to?(:[]) && @data.key?(method_name.to_s)
77
+ @data[method_name.to_s]
78
+ else
79
+ super
80
+ end
81
+ end
82
+
83
+ # Support for respond_to? with dynamic methods
84
+ def respond_to_missing?(method_name, include_private = false)
85
+ (@data.respond_to?(:[]) &&
86
+ (@data.key?(method_name) || @data.key?(method_name.to_s))) || super
87
+ end
88
+ end
89
+ end
90
+ end