soka 0.0.1.beta2

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.
Files changed (45) hide show
  1. checksums.yaml +7 -0
  2. data/.rspec +3 -0
  3. data/.rubocop.yml +365 -0
  4. data/CHANGELOG.md +31 -0
  5. data/CLAUDE.md +213 -0
  6. data/LICENSE +21 -0
  7. data/README.md +650 -0
  8. data/Rakefile +10 -0
  9. data/examples/1_basic.rb +94 -0
  10. data/examples/2_event_handling.rb +120 -0
  11. data/examples/3_memory.rb +182 -0
  12. data/examples/4_hooks.rb +140 -0
  13. data/examples/5_error_handling.rb +85 -0
  14. data/examples/6_retry.rb +164 -0
  15. data/examples/7_tool_conditional.rb +180 -0
  16. data/examples/8_multi_provider.rb +112 -0
  17. data/lib/soka/agent.rb +130 -0
  18. data/lib/soka/agent_tool.rb +146 -0
  19. data/lib/soka/agent_tools/params_validator.rb +139 -0
  20. data/lib/soka/agents/dsl_methods.rb +140 -0
  21. data/lib/soka/agents/hook_manager.rb +68 -0
  22. data/lib/soka/agents/llm_builder.rb +32 -0
  23. data/lib/soka/agents/retry_handler.rb +74 -0
  24. data/lib/soka/agents/tool_builder.rb +78 -0
  25. data/lib/soka/configuration.rb +60 -0
  26. data/lib/soka/engines/base.rb +67 -0
  27. data/lib/soka/engines/concerns/prompt_template.rb +130 -0
  28. data/lib/soka/engines/concerns/response_processor.rb +103 -0
  29. data/lib/soka/engines/react.rb +136 -0
  30. data/lib/soka/engines/reasoning_context.rb +92 -0
  31. data/lib/soka/llm.rb +85 -0
  32. data/lib/soka/llms/anthropic.rb +124 -0
  33. data/lib/soka/llms/base.rb +114 -0
  34. data/lib/soka/llms/concerns/response_parser.rb +47 -0
  35. data/lib/soka/llms/concerns/streaming_handler.rb +78 -0
  36. data/lib/soka/llms/gemini.rb +106 -0
  37. data/lib/soka/llms/openai.rb +97 -0
  38. data/lib/soka/memory.rb +83 -0
  39. data/lib/soka/result.rb +136 -0
  40. data/lib/soka/test_helpers.rb +162 -0
  41. data/lib/soka/thoughts_memory.rb +112 -0
  42. data/lib/soka/version.rb +5 -0
  43. data/lib/soka.rb +49 -0
  44. data/sig/soka.rbs +4 -0
  45. metadata +158 -0
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Soka
4
+ # Global configuration for Soka framework
5
+ class Configuration
6
+ # AI provider configuration
7
+ class AIConfig
8
+ attr_accessor :provider, :model, :api_key, :fallback_provider, :fallback_model, :fallback_api_key
9
+
10
+ def initialize
11
+ @provider = :gemini
12
+ @model = 'gemini-2.5-flash-lite'
13
+ end
14
+ end
15
+
16
+ # Performance-related configuration
17
+ class PerformanceConfig
18
+ attr_accessor :max_iterations, :timeout
19
+
20
+ def initialize
21
+ @max_iterations = 10
22
+ @timeout = 30
23
+ end
24
+ end
25
+
26
+ attr_accessor :tools
27
+
28
+ def initialize
29
+ @ai = AIConfig.new
30
+ @performance = PerformanceConfig.new
31
+ @tools = []
32
+ end
33
+
34
+ # Configure a specific section
35
+ # @param section [Symbol] The section to configure (:ai, :performance, :logging)
36
+ # @yield Configuration block for the section
37
+ # @return [Object] The configuration section
38
+ def configure(section)
39
+ config_object = instance_variable_get("@#{section}")
40
+ yield(config_object) if block_given? && config_object
41
+ config_object
42
+ end
43
+
44
+ # Configuration accessor methods with block support
45
+
46
+ # Access or configure AI settings
47
+ # @yield [AIConfig] Configuration block for AI settings
48
+ # @return [AIConfig] The AI configuration
49
+ def ai(&)
50
+ block_given? ? configure(:ai, &) : @ai
51
+ end
52
+
53
+ # Access or configure performance settings
54
+ # @yield [PerformanceConfig] Configuration block for performance settings
55
+ # @return [PerformanceConfig] The performance configuration
56
+ def performance(&)
57
+ block_given? ? configure(:performance, &) : @performance
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Soka
4
+ module Engines
5
+ # Base class for reasoning engines
6
+ class Base
7
+ attr_reader :agent, :llm, :tools, :max_iterations
8
+
9
+ def initialize(agent, llm, tools, max_iterations)
10
+ @agent = agent
11
+ @llm = llm
12
+ @tools = tools
13
+ @max_iterations = max_iterations
14
+ end
15
+
16
+ def reason(task)
17
+ raise NotImplementedError, "#{self.class} must implement #reason method"
18
+ end
19
+
20
+ protected
21
+
22
+ def find_tool(name)
23
+ tools.find { |tool| tool.class.tool_name == name.to_s.downcase }
24
+ end
25
+
26
+ def execute_tool(tool_name, params = {})
27
+ tool = find_tool(tool_name)
28
+ raise ToolError, "Tool not found: #{tool_name}" unless tool
29
+
30
+ tool.execute(**params)
31
+ end
32
+
33
+ def emit_event(type, content)
34
+ return unless block_given?
35
+
36
+ event = Struct.new(:type, :content).new(type, content)
37
+ yield(event)
38
+ end
39
+
40
+ def build_messages(task)
41
+ messages = [system_message]
42
+ add_memory_messages(messages)
43
+ messages << user_message(task)
44
+ messages
45
+ end
46
+
47
+ def system_message
48
+ { role: 'system', content: system_prompt }
49
+ end
50
+
51
+ def add_memory_messages(messages)
52
+ return unless agent.respond_to?(:memory) && agent.memory
53
+
54
+ messages.concat(agent.memory.to_messages)
55
+ end
56
+
57
+ def user_message(task)
58
+ { role: 'user', content: task }
59
+ end
60
+
61
+ def system_prompt
62
+ # This should be overridden by specific engines
63
+ 'You are a helpful AI assistant.'
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,130 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Soka
4
+ module Engines
5
+ module Concerns
6
+ # Module for handling prompt templates in ReAct engine
7
+ module PromptTemplate
8
+ private
9
+
10
+ def system_prompt
11
+ tools_description = format_tools_description(tools)
12
+
13
+ <<~PROMPT
14
+ You are an AI assistant that uses the ReAct (Reasoning and Acting) framework to solve problems step by step.
15
+
16
+ You have access to the following tools:
17
+ #{tools_description}
18
+
19
+ #{format_instructions}
20
+ PROMPT
21
+ end
22
+
23
+ def format_instructions
24
+ <<~INSTRUCTIONS
25
+ You must follow this exact format for each step:
26
+
27
+ <Thought>Your reasoning about what to do next</Thought>
28
+ <Action>
29
+ Tool: tool_name
30
+ Parameters: {"param1": "value1", "param2": "value2"}
31
+ </Action>
32
+
33
+ STOP HERE after each Action. Do NOT include <Observation> in your response.
34
+ The system will execute the tool and provide the observation.
35
+
36
+ After receiving the observation, you can continue with more Thought/Action cycles or provide a final answer:
37
+
38
+ <Final_Answer>Your complete answer to the user's question</Final_Answer>
39
+
40
+ Important rules:
41
+ 1. Always start with a <Thought> to analyze the problem
42
+ 2. Use tools when you need information or to perform actions
43
+ 3. Parameters MUST be valid JSON format (e.g., {"query": "weather"} not {query: "weather"})
44
+ 4. For tools without parameters, use empty JSON object: {}
45
+ 5. NEVER include <Observation> tags - wait for the system to provide them
46
+ 6. Provide a clear and complete <Final_Answer> when done
47
+ 7. If you cannot complete the task, explain why in the <Final_Answer>
48
+ INSTRUCTIONS
49
+ end
50
+
51
+ def format_tools_description(tools)
52
+ return 'No tools available.' if tools.empty?
53
+
54
+ tools.map do |tool|
55
+ schema = tool.class.to_h
56
+ params_desc = format_parameters(schema[:parameters])
57
+
58
+ "- #{schema[:name]}: #{schema[:description]}\n Parameters: #{params_desc}"
59
+ end.join("\n")
60
+ end
61
+
62
+ def format_parameters(params_schema)
63
+ return 'none' if params_schema[:properties].empty?
64
+
65
+ properties = params_schema[:properties].map do |name, config|
66
+ required = params_schema[:required].include?(name.to_s) ? '(required)' : '(optional)'
67
+ type = config[:type]
68
+ desc = config[:description]
69
+
70
+ "#{name} #{required} [#{type}] - #{desc}"
71
+ end
72
+
73
+ properties.join(', ')
74
+ end
75
+
76
+ def parse_response(text)
77
+ thoughts = extract_tagged_content(text, 'Thought')
78
+ actions = extract_actions(text)
79
+ final_answer = extract_tagged_content(text, 'Final_Answer').first
80
+
81
+ {
82
+ thoughts: thoughts,
83
+ actions: actions,
84
+ final_answer: final_answer
85
+ }
86
+ end
87
+
88
+ def extract_tagged_content(text, tag)
89
+ pattern = %r{<#{tag}>(.*?)</#{tag}>}m
90
+ text.scan(pattern).map { |match| match[0].strip }
91
+ end
92
+
93
+ def extract_actions(text)
94
+ action_blocks = text.scan(%r{<Action>(.*?)</Action>}m)
95
+ action_blocks.filter_map { |block| parse_action_block(block[0]) }
96
+ end
97
+
98
+ def parse_action_block(content)
99
+ content = content.strip
100
+ tool_match = content.match(/Tool:\s*(.+)/)
101
+ params_match = content.match(/Parameters:\s*(.+)/m)
102
+
103
+ return unless tool_match && params_match
104
+
105
+ tool_name = tool_match[1].strip
106
+ params_json = params_match[1].strip
107
+ params = parse_json_params(params_json)
108
+
109
+ { tool: tool_name, params: params }
110
+ end
111
+
112
+ # Parse JSON parameters from action block
113
+ # @param params_json [String] The JSON string to parse
114
+ # @return [Hash] The parsed parameters as a hash with symbol keys
115
+ def parse_json_params(params_json)
116
+ # Clean up the JSON string - remove any trailing commas or whitespace
117
+ cleaned_json = params_json.strip.gsub(/,\s*}/, '}').gsub(/,\s*\]/, ']')
118
+ JSON.parse(cleaned_json, symbolize_names: true)
119
+ rescue JSON::ParserError
120
+ # Return empty hash to continue when JSON parsing fails
121
+ {}
122
+ end
123
+
124
+ def format_observation(observation)
125
+ "<Observation>#{observation}</Observation>"
126
+ end
127
+ end
128
+ end
129
+ end
130
+ end
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Soka
4
+ module Engines
5
+ module Concerns
6
+ # Module for processing responses in ReAct engine
7
+ module ResponseProcessor
8
+ include Concerns::PromptTemplate
9
+
10
+ private
11
+
12
+ # Process thoughts from parsed response
13
+ # @param parsed_thoughts [Array<String>] The thoughts to process
14
+ # @param context [ReasoningContext] The reasoning context
15
+ def process_thoughts(parsed_thoughts, context)
16
+ parsed_thoughts.each do |thought|
17
+ context.emit_event(:thought, thought)
18
+ context.add_thought(thought)
19
+ end
20
+ end
21
+
22
+ # Process an action from the response
23
+ # @param action [Hash] The action to process
24
+ # @param context [ReasoningContext] The reasoning context
25
+ # @param content [String] The raw response content
26
+ def process_action(action, context, content)
27
+ context.emit_event(:action, action)
28
+
29
+ begin
30
+ observation = execute_tool(action[:tool], action[:params])
31
+ context.emit_event(:observation, observation)
32
+ add_observation_to_messages(context, content, observation)
33
+ context.update_last_thought(action: action, observation: observation)
34
+ rescue ToolError => e
35
+ handle_tool_error(e, context, action, content)
36
+ end
37
+ end
38
+
39
+ # Add observation to messages in the context
40
+ # @param context [ReasoningContext] The reasoning context
41
+ # @param content [String] The assistant's response content
42
+ # @param observation [String] The observation to add
43
+ def add_observation_to_messages(context, content, observation)
44
+ observation_text = format_observation(observation)
45
+ context.add_message(role: 'assistant', content: content)
46
+ context.add_message(role: 'user', content: observation_text)
47
+ end
48
+
49
+ # Handle tool execution errors
50
+ # @param error [ToolError] The error that occurred
51
+ # @param context [ReasoningContext] The reasoning context
52
+ # @param action [Hash] The action that failed
53
+ # @param content [String] The raw response content
54
+ # @raise [ToolError] Re-raises the error to trigger on_error hooks and terminate reasoning
55
+ def handle_tool_error(error, context, action, content)
56
+ error_message = "Tool error: #{error.message}"
57
+ context.emit_event(:error, error_message)
58
+
59
+ # Add error as observation so AI can see what happened
60
+ add_observation_to_messages(context, content, error_message)
61
+ context.update_last_thought(action: action, observation: error_message)
62
+
63
+ # Re-raise the error to propagate to agent level
64
+ # This will trigger on_error hook and terminate the reasoning process
65
+ # If you want reasoning to continue after tool errors, comment out this line
66
+ # raise error
67
+ end
68
+
69
+ # Execute a tool with the given name and input
70
+ # @param tool_name [String] The name of the tool to execute
71
+ # @param tool_input [Hash] The input parameters for the tool
72
+ # @return [String] The result of the tool execution
73
+ # @raise [ToolError] If the tool is not found or execution fails
74
+ def execute_tool(tool_name, tool_input)
75
+ tool = tools.find { |t| t.class.tool_name == tool_name }
76
+ raise ToolError, "Tool '#{tool_name}' not found" unless tool
77
+
78
+ tool.call(**symbolize_keys(tool_input))
79
+ rescue StandardError => e
80
+ # Re-raise as ToolError to be caught by process_action
81
+ raise ToolError, "Error executing tool: #{e.message}"
82
+ end
83
+
84
+ def symbolize_keys(hash)
85
+ return {} unless hash.is_a?(Hash)
86
+
87
+ hash.transform_keys(&:to_sym)
88
+ end
89
+
90
+ # Handle case when no action is found in response
91
+ # @param context [ReasoningContext] The reasoning context
92
+ # @param content [String] The raw response content
93
+ def handle_no_action(context, content)
94
+ context.add_message(role: 'assistant', content: content)
95
+ context.add_message(
96
+ role: 'user',
97
+ content: 'Please follow the exact format with <Thought>, <Action>, and <Final_Answer> tags.'
98
+ )
99
+ end
100
+ end
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,136 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module Soka
6
+ module Engines
7
+ # ReAct (Reasoning and Acting) engine implementation
8
+ class React < Base
9
+ include Concerns::ResponseProcessor
10
+ include Concerns::PromptTemplate
11
+
12
+ ReasonResult = Struct.new(:input, :thoughts, :final_answer, :status, :error, :confidence_score,
13
+ keyword_init: true) do
14
+ def successful?
15
+ status == :success
16
+ end
17
+ end
18
+
19
+ # Main reasoning entry point
20
+ # @param task [String] The task to process
21
+ # @yield [event] Optional block to handle events during execution
22
+ # @return [ReasonResult] The result of the reasoning process
23
+ def reason(task, &block)
24
+ context = ReasoningContext.new(task: task, event_handler: block, max_iterations: max_iterations)
25
+ context.messages = build_messages(task)
26
+
27
+ result = iterate_reasoning(context)
28
+ result || max_iterations_result(context)
29
+ end
30
+
31
+ # Iterate through reasoning cycles
32
+ # @param context [ReasoningContext] The reasoning context
33
+ # @return [ReasonResult, nil] The result if found, nil otherwise
34
+ def iterate_reasoning(context)
35
+ max_iterations.times do
36
+ result = process_iteration(context)
37
+ return result if result
38
+
39
+ context.increment_iteration!
40
+ end
41
+ nil
42
+ end
43
+
44
+ # Build result when max iterations reached
45
+ # @param context [ReasoningContext] The reasoning context
46
+ # @return [ReasonResult] The error result
47
+ def max_iterations_result(context)
48
+ context.emit_event(:error, "Maximum iterations (#{context.max_iterations}) reached")
49
+ build_result(
50
+ input: context.task,
51
+ thoughts: context.thoughts,
52
+ final_answer: "I couldn't complete the task within the maximum number of iterations.",
53
+ status: :max_iterations_reached
54
+ )
55
+ end
56
+
57
+ private
58
+
59
+ # Process a single iteration of reasoning
60
+ # @param context [ReasoningContext] The reasoning context
61
+ # @return [ReasonResult, nil] The result if final answer found, nil otherwise
62
+ def process_iteration(context)
63
+ response = llm.chat(context.messages)
64
+ content = response.content
65
+ context.parsed_response = parse_response(content)
66
+
67
+ process_parsed_response(context, content)
68
+ end
69
+
70
+ # Process the parsed response
71
+ # @param context [ReasoningContext] The reasoning context
72
+ # @param content [String] The raw response content
73
+ # @return [ReasonResult, nil] The result if final answer found, nil otherwise
74
+ def process_parsed_response(context, content)
75
+ parsed = context.parsed_response
76
+ process_thoughts(parsed[:thoughts], context)
77
+
78
+ return process_final_answer(parsed[:final_answer], context) if parsed[:final_answer]
79
+
80
+ handle_actions_or_no_action(parsed, context, content)
81
+ nil
82
+ end
83
+
84
+ # Handle either actions or no action in response
85
+ # @param parsed [Hash] The parsed response
86
+ # @param context [ReasoningContext] The reasoning context
87
+ # @param content [String] The raw response content
88
+ def handle_actions_or_no_action(parsed, context, content)
89
+ if parsed[:actions].any?
90
+ process_action(parsed[:actions].first, context, content)
91
+ else
92
+ handle_no_action(context, content)
93
+ end
94
+ end
95
+
96
+ # Process the final answer
97
+ # @param final_answer [String] The final answer from the LLM
98
+ # @param context [ReasoningContext] The reasoning context
99
+ # @return [ReasonResult] The success result
100
+ def process_final_answer(final_answer, context)
101
+ context.emit_event(:final_answer, final_answer)
102
+ build_result(
103
+ input: context.task,
104
+ thoughts: context.thoughts,
105
+ final_answer: final_answer,
106
+ status: :success
107
+ )
108
+ end
109
+
110
+ def build_result(input:, thoughts:, final_answer:, status:, error: nil)
111
+ result = {
112
+ input: input,
113
+ thoughts: thoughts,
114
+ final_answer: final_answer,
115
+ status: status
116
+ }
117
+
118
+ result[:error] = error if error
119
+
120
+ # Calculate confidence score based on iterations and status
121
+ result[:confidence_score] = calculate_confidence_score(thoughts, status)
122
+
123
+ ReasonResult.new(**result)
124
+ end
125
+
126
+ def calculate_confidence_score(thoughts, status)
127
+ return 0.0 if status != :success
128
+
129
+ base_score = 0.85
130
+ iteration_penalty = thoughts.length * 0.05
131
+
132
+ [base_score - iteration_penalty, 0.5].max
133
+ end
134
+ end
135
+ end
136
+ end
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Soka
4
+ module Engines
5
+ # Context object that encapsulates all data needed during reasoning process
6
+ # This eliminates the need to pass multiple parameters and blocks through method chains
7
+ class ReasoningContext
8
+ # Event structure for emitting events
9
+ Event = Struct.new(:type, :content)
10
+
11
+ attr_accessor :messages, :thoughts, :task, :iteration, :parsed_response
12
+ attr_reader :event_handler, :max_iterations
13
+
14
+ # Initialize a new reasoning context
15
+ # @param task [String] The task to be processed
16
+ # @param event_handler [Proc, nil] Optional block to handle events
17
+ # @param max_iterations [Integer] Maximum number of reasoning iterations
18
+ def initialize(task:, event_handler: nil, max_iterations: 10)
19
+ @task = task
20
+ @event_handler = event_handler
21
+ @max_iterations = max_iterations
22
+ @messages = []
23
+ @thoughts = []
24
+ @iteration = 0
25
+ @parsed_response = nil
26
+ end
27
+
28
+ # Emit an event to the event handler if present
29
+ # @param type [Symbol] The type of event (e.g., :thought, :action, :observation)
30
+ # @param content [String, Hash] The content of the event
31
+ def emit_event(type, content)
32
+ return unless @event_handler
33
+
34
+ event = Event.new(type, content)
35
+ @event_handler.call(event)
36
+ end
37
+
38
+ # Check if we've reached the maximum number of iterations
39
+ # @return [Boolean] true if max iterations reached
40
+ def max_iterations_reached?
41
+ @iteration >= @max_iterations
42
+ end
43
+
44
+ # Increment the iteration counter
45
+ # @return [Integer] The new iteration count
46
+ def increment_iteration!
47
+ @iteration += 1
48
+ end
49
+
50
+ # Get the current iteration number (1-based for display)
51
+ # @return [Integer] The current iteration number for display
52
+ def current_step
53
+ @iteration + 1
54
+ end
55
+
56
+ # Add a thought to the thoughts collection
57
+ # @param thought [String] The thought content
58
+ # @param action [Hash, nil] Optional action associated with the thought
59
+ # @param observation [String, nil] Optional observation from action
60
+ def add_thought(thought, action: nil, observation: nil)
61
+ thought_data = { step: current_step, thought: thought }
62
+ thought_data[:action] = action if action
63
+ thought_data[:observation] = observation if observation
64
+ @thoughts << thought_data
65
+ end
66
+
67
+ # Update the last thought with action and observation
68
+ # @param action [Hash] The action that was taken
69
+ # @param observation [String] The observation from the action
70
+ def update_last_thought(action:, observation:)
71
+ return if @thoughts.empty?
72
+
73
+ @thoughts.last[:action] = action
74
+ @thoughts.last[:observation] = observation
75
+ end
76
+
77
+ # Add a message to the conversation
78
+ # @param role [String] The role of the message sender
79
+ # @param content [String] The message content
80
+ def add_message(role:, content:)
81
+ @messages << { role: role, content: content }
82
+ end
83
+
84
+ # Get the last assistant message content
85
+ # @return [String, nil] The content of the last assistant message
86
+ def last_assistant_content
87
+ last_assistant = @messages.reverse.find { |msg| msg[:role] == 'assistant' }
88
+ last_assistant&.fetch(:content, nil)
89
+ end
90
+ end
91
+ end
92
+ end
data/lib/soka/llm.rb ADDED
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Soka
4
+ # LLM wrapper class that delegates to specific provider implementations
5
+ class LLM
6
+ attr_reader :provider
7
+
8
+ # Initialize LLM with specified provider
9
+ # @param provider_name [Symbol, nil] The provider to use (:gemini, :openai, :anthropic)
10
+ # @param options [Hash] Provider-specific options (model, api_key, etc.)
11
+ def initialize(provider_name = nil, **options)
12
+ provider_name ||= Soka.configuration.ai.provider
13
+
14
+ # Merge configuration options if no explicit options provided
15
+ if options.empty? && provider_name == Soka.configuration.ai.provider
16
+ config = Soka.configuration.ai
17
+ options = {
18
+ api_key: config.api_key,
19
+ model: config.model
20
+ }.compact
21
+ end
22
+
23
+ @provider = create_provider(provider_name, **options)
24
+ end
25
+
26
+ # Chat with the LLM
27
+ # @param messages [Array<Hash>] Array of message hashes with role and content
28
+ # @param params [Hash] Additional parameters for the chat
29
+ # @return [LLMs::Result] The chat result
30
+ def chat(messages, **params)
31
+ @provider.chat(messages, **params)
32
+ end
33
+
34
+ # Stream chat responses
35
+ # @param messages [Array<Hash>] Array of message hashes
36
+ # @param params [Hash] Additional parameters
37
+ # @yield [chunk] Yields each response chunk
38
+ def streaming_chat(messages, **params, &)
39
+ @provider.streaming_chat(messages, **params, &)
40
+ end
41
+
42
+ # Check if provider supports streaming
43
+ # @return [Boolean] True if streaming is supported
44
+ def supports_streaming?
45
+ @provider.supports_streaming?
46
+ end
47
+
48
+ # Get the model being used
49
+ # @return [String] The model name
50
+ def model
51
+ @provider.model
52
+ end
53
+
54
+ # Build LLM instance from configuration object
55
+ # @param config [Object] Configuration with provider, model, and api_key
56
+ # @return [LLM] New LLM instance
57
+ def self.build(config)
58
+ options = {
59
+ model: config.model,
60
+ api_key: config.api_key
61
+ }.compact
62
+
63
+ new(config.provider, **options)
64
+ end
65
+
66
+ private
67
+
68
+ # Create the appropriate provider instance
69
+ # @param provider_name [Symbol] Provider type
70
+ # @param options [Hash] Provider options
71
+ # @return [LLMs::Base] Provider instance
72
+ def create_provider(provider_name, **)
73
+ case provider_name.to_sym
74
+ when :gemini
75
+ LLMs::Gemini.new(**)
76
+ when :openai
77
+ LLMs::OpenAI.new(**)
78
+ when :anthropic
79
+ LLMs::Anthropic.new(**)
80
+ else
81
+ raise LLMError, "Unknown LLM provider: #{provider_name}"
82
+ end
83
+ end
84
+ end
85
+ end