{"repo": "budu/nanobot.rb", "files": {"lib/nanobot/providers/rubyllm_provider.rb": {"aae713659f7b9dd59bcb6670f5026eb293508329": {"t": 1785745748.5750208, "n": 10056, "h": "9235ba43f2d56309", "c": "# frozen_string_literal: true\n\nrequire 'ruby_llm'\nrequire 'logger'\nrequire_relative 'base'\n\nmodule Nanobot\n module Providers\n # LLM provider implementation backed by the RubyLLM gem.\n # Supports all RubyLLM backends (Anthropic, OpenAI, Gemini, DeepSeek,\n # OpenRouter, Mistral, Perplexity, xAI, Ollama, GPUStack, Bedrock, etc.).\n class RubyLLMProvider < LLMProvider\n # Lightweight struct for replaying tool calls through RubyLLM message history.\n # Includes thought_signature for Gemini provider compatibility.\n ToolCallProxy = Struct.new(:id, :name, :arguments, :thought_signature)\n\n # Maps nanobot provider names to their RubyLLM api_key config attribute.\n # Providers not listed here (or mapped to nil) have no api_key setting.\n PROVIDER_KEY_MAP = {\n 'anthropic' => :anthropic_api_key,\n 'openai' => :openai_api_key,\n 'gemini' => :gemini_api_key,\n 'deepseek' => :deepseek_api_key,\n 'openrouter' => :openrouter_api_key,\n 'mistral' => :mistral_api_key,\n 'perplexity' => :perplexity_api_key,\n 'xai' => :xai_api_key,\n 'gpustack' => :gpustack_api_key,\n 'bedrock' => :bedrock_api_key,\n 'groq' => :openai_api_key\n }.freeze\n\n # Maps nanobot provider names to their RubyLLM api_base config attribute.\n # Only providers that support a custom base URL are listed.\n PROVIDER_BASE_MAP = {\n 'openai' => :openai_api_base,\n 'gemini' => :gemini_api_base,\n 'ollama' => :ollama_api_base,\n 'gpustack' => :gpustack_api_base\n }.freeze\n\n attr_reader :logger, :default_model\n\n # @param api_key [String, nil] API key for the provider\n # @param api_base [String, nil] custom API base URL\n # @param default_model [String, nil] model identifier (defaults to claude-haiku-4-5)\n # @param provider [String] provider backend name (anthropic, openai, deepseek, groq, openrouter)\n # @param logger [Logger, nil] logger instance (defaults to null logger)\n def initialize(api_key: nil, api_base: nil, default_model: nil, provider: 'anthropic', logger: nil)\n super()\n @api_key = api_key\n @api_base = api_base\n @provider = provider.to_s\n @default_model = default_model || 'claude-haiku-4-5'\n @logger = logger || Logger.new(IO::NULL)\n @chat = nil\n\n configure_client\n end\n\n # Chat with tools using RubyLLM's provider API directly\n # This bypasses RubyLLM's automatic tool execution to give us control\n # @param messages [Array] message history with system, user, assistant roles\n # @param tools [Array] array of tool instances\n # @param model [String, nil] model to use\n # @param max_tokens [Integer] max tokens (not used currently)\n # @param temperature [Float] sampling temperature\n # @return [LLMResponse] response with content and tool calls\n def chat(messages:, tools: nil, model: nil, max_tokens: 4096, temperature: 0.7)\n model_to_use = model || @default_model\n log_request(model_to_use, temperature, max_tokens, messages, tools)\n\n response = call_provider(model_to_use, messages, tools, temperature)\n log_response(response)\n\n LLMResponse.new(content: response.content, tool_calls: convert_tool_calls(response), finish_reason: 'stop')\n rescue StandardError => e\n @logger.error \"Error calling LLM: #{e.message}\"\n @logger.error e.backtrace.join(\"\\n\")\n\n LLMResponse.new(content: \"Error calling LLM: #{e.message}\", tool_calls: [], finish_reason: 'error')\n end\n\n private\n\n # Resolve the model and invoke the RubyLLM provider API directly.\n # @param model_id [String] model identifier\n # @param messages [Array] message history\n # @param tools [Array, nil] available tools\n # @param temperature [Float] sampling temperature\n # @return [RubyLLM::Message] provider response\n def call_provider(model_id, messages, tools, temperature)\n model_obj, provider = RubyLLM::Models.resolve(model_id, config: RubyLLM.config)\n\n provider.complete(\n build_rubyllm_messages(messages),\n tools: build_tools_hash(tools),\n temperature: temperature,\n model: model_obj,\n params: {},\n headers: {},\n schema: nil,\n thinking: nil\n )\n end\n\n # Log the outgoing LLM request details at debug level.\n def log_request(model, temperature, max_tokens, messages, tools)\n @logger.debug '=== LLM REQUEST ==='\n @logger.debug \"Model: #{model} | Temperature: #{temperature} | Max tokens: #{max_tokens}\"\n @logger.debug \"Messages: #{messages.length} total\"\n\n messages.each_with_index do |msg, i|\n log_message(msg, i)\n end\n\n log_tools(tools)\n end\n\n # Log a single message's role, content, and any tool calls.\n def log_message(msg, index)\n role = msg[:role]\n content = msg[:content]\n @logger.debug \" [#{index}] role=#{role} content_length=#{content&.length || 0}\"\n if role == 'system'\n @logger.debug \" [#{index}] system prompt (first 500 chars): #{content&.slice(0, 500)}\"\n else\n @logger.debug \" [#{index}] content: #{content}\"\n end\n msg[:tool_calls]&.each do |tc|\n func = tc[:function] || tc['function']\n name = func[:name] || func['name']\n args = func[:arguments] || func['arguments']\n @logger.debug \" [#{index}] tool_call: #{name}(#{args})\"\n end\n @logger.debug \" [#{index}] tool_call_id=#{msg[:tool_call_id]}\" if msg[:tool_call_id]\n end\n\n # Log available tool names and descriptions.\n def log_tools(tools)\n return unless tools && !tools.empty?\n\n @logger.debug \"Tools (#{tools.length}):\"\n tools.each do |tool|\n tool_name = tool.respond_to?(:name) ? tool.name : tool[:name] || tool.dig(:function, :name)\n tool_desc = tool.respond_to?(:description) ? tool.description : nil\n @logger.debug \" - #{tool_name}#{\": #{tool_desc.slice(0, 120)}\" if tool_desc}\"\n end\n end\n\n # Log the LLM response content and tool calls.\n def log_response(response)\n @logger.debug '=== LLM RESPONSE ==='\n @logger.debug \"Content length: #{response.content&.length || 0}\"\n @logger.debug \"Content: #{response.content}\"\n if response.tool_call?\n @logger.debug \"Tool calls: #{response.tool_calls.length}\"\n response.tool_calls.each_value do |tc|\n @logger.debug \" tool_call id=#{tc.id} name=#{tc.name} args=#{tc.arguments}\"\n end\n else\n @logger.debug 'Tool calls: none'\n end\n @logger.debug '=== END LLM RESPONSE ==='\n end\n\n # Convert internal message hashes to RubyLLM::Message objects.\n # @param messages [Array] message hashes with :role, :content, :tool_calls, :tool_call_id\n # @return [Array]\n def build_rubyllm_messages(messages)\n messages.map do |msg|\n RubyLLM::Message.new(\n role: msg[:role].to_sym,\n content: msg[:content] || '',\n tool_calls: convert_tool_calls_to_rubyllm(msg[:tool_calls]),\n tool_call_id: msg[:tool_call_id]\n )\n end\n end\n\n # Convert an array of tool instances to a hash keyed by tool name.\n # @param tools [Array, nil] tool instances\n # @return [Hash{Symbol => RubyLLM::Tool}]\n def build_tools_hash(tools)\n tools_hash = {}\n if tools && !tools.empty?\n tools.each do |tool|\n tools_hash[tool.name.to_sym] = tool\n end\n end\n tools_hash\n end\n\n # Configure the RubyLLM client with the appropriate API key and base URL for the provider.\n def configure_client\n RubyLLM.configure do |config|\n key_attr = PROVIDER_KEY_MAP[@provider]\n config.send(:\"#{key_attr}=\", @api_key) if key_attr && @api_key\n\n base_attr = PROVIDER_BASE_MAP[@provider]\n config.send(:\"#{base_attr}=\", @api_base) if base_attr && @api_base\n end\n\n @logger.debug \"Configured RubyLLM for provider: #{@provider}\"\n end\n\n # Convert RubyLLM tool calls to our ToolCallRequest format.\n # @param response [RubyLLM::Message] LLM response\n # @return [Array]\n def convert_tool_calls(response)\n return [] unless response.tool_call?\n\n response.tool_calls.map do |_id, tool_call|\n ToolCallRequest.new(\n id: tool_call.id,\n name: tool_call.name,\n arguments: tool_call.arguments,\n thought_signature: tool_call.respond_to?(:thought_signature) ? tool_call.thought_signature : nil\n )\n end\n end\n\n # Convert our tool call format to RubyLLM format for history replay.\n # @param tool_calls [Array, nil] tool calls in OpenAI-style format\n # @return [Hash{String => ToolCallProxy}, nil] tool calls keyed by ID\n def convert_tool_calls_to_rubyllm(tool_calls)\n return nil unless tool_calls && !tool_calls.empty?\n\n # Convert array of tool calls to hash keyed by ID\n tool_calls.each_with_object({}) do |tc, hash|\n func = tc['function'] || tc[:function]\n args = func['arguments'] || func[:arguments]\n\n # Parse arguments if they're a JSON string\n require 'json'\n parsed_args = args.is_a?(String) ? JSON.parse(args) : args\n\n # Create a simple object to represent the tool call\n tool_call_id = tc['id'] || tc[:id]\n tool_call_obj = ToolCallProxy.new(\n tool_call_id,\n func['name'] || func[:name],\n parsed_args,\n tc['thought_signature'] || tc[:thought_signature]\n )\n\n hash[tool_call_id] = tool_call_obj\n end\n rescue JSON::ParserError\n nil\n end\n end\n end\nend\n"}, "d5f25cc2c6ffdb1cb38bf1362e4b9c496c0cc453": {"t": 1785756128.4892275, "n": 10056, "h": "9235ba43f2d56309", "c": "# frozen_string_literal: true\n\nrequire 'ruby_llm'\nrequire 'logger'\nrequire_relative 'base'\n\nmodule Nanobot\n module Providers\n # LLM provider implementation backed by the RubyLLM gem.\n # Supports all RubyLLM backends (Anthropic, OpenAI, Gemini, DeepSeek,\n # OpenRouter, Mistral, Perplexity, xAI, Ollama, GPUStack, Bedrock, etc.).\n class RubyLLMProvider < LLMProvider\n # Lightweight struct for replaying tool calls through RubyLLM message history.\n # Includes thought_signature for Gemini provider compatibility.\n ToolCallProxy = Struct.new(:id, :name, :arguments, :thought_signature)\n\n # Maps nanobot provider names to their RubyLLM api_key config attribute.\n # Providers not listed here (or mapped to nil) have no api_key setting.\n PROVIDER_KEY_MAP = {\n 'anthropic' => :anthropic_api_key,\n 'openai' => :openai_api_key,\n 'gemini' => :gemini_api_key,\n 'deepseek' => :deepseek_api_key,\n 'openrouter' => :openrouter_api_key,\n 'mistral' => :mistral_api_key,\n 'perplexity' => :perplexity_api_key,\n 'xai' => :xai_api_key,\n 'gpustack' => :gpustack_api_key,\n 'bedrock' => :bedrock_api_key,\n 'groq' => :openai_api_key\n }.freeze\n\n # Maps nanobot provider names to their RubyLLM api_base config attribute.\n # Only providers that support a custom base URL are listed.\n PROVIDER_BASE_MAP = {\n 'openai' => :openai_api_base,\n 'gemini' => :gemini_api_base,\n 'ollama' => :ollama_api_base,\n 'gpustack' => :gpustack_api_base\n }.freeze\n\n attr_reader :logger, :default_model\n\n # @param api_key [String, nil] API key for the provider\n # @param api_base [String, nil] custom API base URL\n # @param default_model [String, nil] model identifier (defaults to claude-haiku-4-5)\n # @param provider [String] provider backend name (anthropic, openai, deepseek, groq, openrouter)\n # @param logger [Logger, nil] logger instance (defaults to null logger)\n def initialize(api_key: nil, api_base: nil, default_model: nil, provider: 'anthropic', logger: nil)\n super()\n @api_key = api_key\n @api_base = api_base\n @provider = provider.to_s\n @default_model = default_model || 'claude-haiku-4-5'\n @logger = logger || Logger.new(IO::NULL)\n @chat = nil\n\n configure_client\n end\n\n # Chat with tools using RubyLLM's provider API directly\n # This bypasses RubyLLM's automatic tool execution to give us control\n # @param messages [Array] message history with system, user, assistant roles\n # @param tools [Array] array of tool instances\n # @param model [String, nil] model to use\n # @param max_tokens [Integer] max tokens (not used currently)\n # @param temperature [Float] sampling temperature\n # @return [LLMResponse] response with content and tool calls\n def chat(messages:, tools: nil, model: nil, max_tokens: 4096, temperature: 0.7)\n model_to_use = model || @default_model\n log_request(model_to_use, temperature, max_tokens, messages, tools)\n\n response = call_provider(model_to_use, messages, tools, temperature)\n log_response(response)\n\n LLMResponse.new(content: response.content, tool_calls: convert_tool_calls(response), finish_reason: 'stop')\n rescue StandardError => e\n @logger.error \"Error calling LLM: #{e.message}\"\n @logger.error e.backtrace.join(\"\\n\")\n\n LLMResponse.new(content: \"Error calling LLM: #{e.message}\", tool_calls: [], finish_reason: 'error')\n end\n\n private\n\n # Resolve the model and invoke the RubyLLM provider API directly.\n # @param model_id [String] model identifier\n # @param messages [Array] message history\n # @param tools [Array, nil] available tools\n # @param temperature [Float] sampling temperature\n # @return [RubyLLM::Message] provider response\n def call_provider(model_id, messages, tools, temperature)\n model_obj, provider = RubyLLM::Models.resolve(model_id, config: RubyLLM.config)\n\n provider.complete(\n build_rubyllm_messages(messages),\n tools: build_tools_hash(tools),\n temperature: temperature,\n model: model_obj,\n params: {},\n headers: {},\n schema: nil,\n thinking: nil\n )\n end\n\n # Log the outgoing LLM request details at debug level.\n def log_request(model, temperature, max_tokens, messages, tools)\n @logger.debug '=== LLM REQUEST ==='\n @logger.debug \"Model: #{model} | Temperature: #{temperature} | Max tokens: #{max_tokens}\"\n @logger.debug \"Messages: #{messages.length} total\"\n\n messages.each_with_index do |msg, i|\n log_message(msg, i)\n end\n\n log_tools(tools)\n end\n\n # Log a single message's role, content, and any tool calls.\n def log_message(msg, index)\n role = msg[:role]\n content = msg[:content]\n @logger.debug \" [#{index}] role=#{role} content_length=#{content&.length || 0}\"\n if role == 'system'\n @logger.debug \" [#{index}] system prompt (first 500 chars): #{content&.slice(0, 500)}\"\n else\n @logger.debug \" [#{index}] content: #{content}\"\n end\n msg[:tool_calls]&.each do |tc|\n func = tc[:function] || tc['function']\n name = func[:name] || func['name']\n args = func[:arguments] || func['arguments']\n @logger.debug \" [#{index}] tool_call: #{name}(#{args})\"\n end\n @logger.debug \" [#{index}] tool_call_id=#{msg[:tool_call_id]}\" if msg[:tool_call_id]\n end\n\n # Log available tool names and descriptions.\n def log_tools(tools)\n return unless tools && !tools.empty?\n\n @logger.debug \"Tools (#{tools.length}):\"\n tools.each do |tool|\n tool_name = tool.respond_to?(:name) ? tool.name : tool[:name] || tool.dig(:function, :name)\n tool_desc = tool.respond_to?(:description) ? tool.description : nil\n @logger.debug \" - #{tool_name}#{\": #{tool_desc.slice(0, 120)}\" if tool_desc}\"\n end\n end\n\n # Log the LLM response content and tool calls.\n def log_response(response)\n @logger.debug '=== LLM RESPONSE ==='\n @logger.debug \"Content length: #{response.content&.length || 0}\"\n @logger.debug \"Content: #{response.content}\"\n if response.tool_call?\n @logger.debug \"Tool calls: #{response.tool_calls.length}\"\n response.tool_calls.each_value do |tc|\n @logger.debug \" tool_call id=#{tc.id} name=#{tc.name} args=#{tc.arguments}\"\n end\n else\n @logger.debug 'Tool calls: none'\n end\n @logger.debug '=== END LLM RESPONSE ==='\n end\n\n # Convert internal message hashes to RubyLLM::Message objects.\n # @param messages [Array] message hashes with :role, :content, :tool_calls, :tool_call_id\n # @return [Array]\n def build_rubyllm_messages(messages)\n messages.map do |msg|\n RubyLLM::Message.new(\n role: msg[:role].to_sym,\n content: msg[:content] || '',\n tool_calls: convert_tool_calls_to_rubyllm(msg[:tool_calls]),\n tool_call_id: msg[:tool_call_id]\n )\n end\n end\n\n # Convert an array of tool instances to a hash keyed by tool name.\n # @param tools [Array, nil] tool instances\n # @return [Hash{Symbol => RubyLLM::Tool}]\n def build_tools_hash(tools)\n tools_hash = {}\n if tools && !tools.empty?\n tools.each do |tool|\n tools_hash[tool.name.to_sym] = tool\n end\n end\n tools_hash\n end\n\n # Configure the RubyLLM client with the appropriate API key and base URL for the provider.\n def configure_client\n RubyLLM.configure do |config|\n key_attr = PROVIDER_KEY_MAP[@provider]\n config.send(:\"#{key_attr}=\", @api_key) if key_attr && @api_key\n\n base_attr = PROVIDER_BASE_MAP[@provider]\n config.send(:\"#{base_attr}=\", @api_base) if base_attr && @api_base\n end\n\n @logger.debug \"Configured RubyLLM for provider: #{@provider}\"\n end\n\n # Convert RubyLLM tool calls to our ToolCallRequest format.\n # @param response [RubyLLM::Message] LLM response\n # @return [Array]\n def convert_tool_calls(response)\n return [] unless response.tool_call?\n\n response.tool_calls.map do |_id, tool_call|\n ToolCallRequest.new(\n id: tool_call.id,\n name: tool_call.name,\n arguments: tool_call.arguments,\n thought_signature: tool_call.respond_to?(:thought_signature) ? tool_call.thought_signature : nil\n )\n end\n end\n\n # Convert our tool call format to RubyLLM format for history replay.\n # @param tool_calls [Array, nil] tool calls in OpenAI-style format\n # @return [Hash{String => ToolCallProxy}, nil] tool calls keyed by ID\n def convert_tool_calls_to_rubyllm(tool_calls)\n return nil unless tool_calls && !tool_calls.empty?\n\n # Convert array of tool calls to hash keyed by ID\n tool_calls.each_with_object({}) do |tc, hash|\n func = tc['function'] || tc[:function]\n args = func['arguments'] || func[:arguments]\n\n # Parse arguments if they're a JSON string\n require 'json'\n parsed_args = args.is_a?(String) ? JSON.parse(args) : args\n\n # Create a simple object to represent the tool call\n tool_call_id = tc['id'] || tc[:id]\n tool_call_obj = ToolCallProxy.new(\n tool_call_id,\n func['name'] || func[:name],\n parsed_args,\n tc['thought_signature'] || tc[:thought_signature]\n )\n\n hash[tool_call_id] = tool_call_obj\n end\n rescue JSON::ParserError\n nil\n end\n end\n end\nend\n"}, "c83ec5272ed5beb74fe8e5c0fd042ab75a51ba58": {"t": 1785756130.035347, "n": 9993, "h": "c01e934b012ef39f", "c": "# frozen_string_literal: true\n\nrequire 'ruby_llm'\nrequire 'logger'\nrequire_relative 'base'\n\nmodule Nanobot\n module Providers\n # LLM provider implementation backed by the RubyLLM gem.\n # Supports all RubyLLM backends (Anthropic, OpenAI, Gemini, DeepSeek,\n # OpenRouter, Mistral, Perplexity, xAI, Ollama, GPUStack, Bedrock, etc.).\n class RubyLLMProvider < LLMProvider\n # Lightweight struct for replaying tool calls through RubyLLM message history.\n # Includes thought_signature for Gemini provider compatibility.\n ToolCallProxy = Struct.new(:id, :name, :arguments, :thought_signature)\n\n # Maps nanobot provider names to their RubyLLM api_key config attribute.\n # Providers not listed here (or mapped to nil) have no api_key setting.\n PROVIDER_KEY_MAP = {\n 'anthropic' => :anthropic_api_key,\n 'openai' => :openai_api_key,\n 'gemini' => :gemini_api_key,\n 'deepseek' => :deepseek_api_key,\n 'openrouter' => :openrouter_api_key,\n 'mistral' => :mistral_api_key,\n 'perplexity' => :perplexity_api_key,\n 'xai' => :xai_api_key,\n 'gpustack' => :gpustack_api_key,\n 'bedrock' => :bedrock_api_key,\n 'groq' => :openai_api_key\n }.freeze\n\n # Maps nanobot provider names to their RubyLLM api_base config attribute.\n # Only providers that support a custom base URL are listed.\n PROVIDER_BASE_MAP = {\n 'openai' => :openai_api_base,\n 'gemini' => :gemini_api_base,\n 'ollama' => :ollama_api_base,\n 'gpustack' => :gpustack_api_base\n }.freeze\n\n attr_reader :logger, :default_model\n\n # @param api_key [String, nil] API key for the provider\n # @param api_base [String, nil] custom API base URL\n # @param default_model [String, nil] model identifier (defaults to claude-haiku-4-5)\n # @param provider [String] provider backend name (anthropic, openai, deepseek, groq, openrouter)\n # @param logger [Logger, nil] logger instance (defaults to null logger)\n def initialize(api_key: nil, api_base: nil, default_model: nil, provider: 'anthropic', logger: nil)\n super()\n @api_key = api_key\n @api_base = api_base\n @provider = provider.to_s\n @default_model = default_model || 'claude-haiku-4-5'\n @logger = logger || Logger.new(IO::NULL)\n @chat = nil\n\n configure_client\n end\n\n # Chat with tools using RubyLLM's provider API directly\n # This bypasses RubyLLM's automatic tool execution to give us control\n # @param messages [Array] message history with system, user, assistant roles\n # @param tools [Array] array of tool instances\n # @param model [String, nil] model to use\n # @param max_tokens [Integer] max tokens (not used currently)\n # @param temperature [Float] sampling temperature\n # @return [LLMResponse] response with content and tool calls\n def chat(messages:, tools: nil, model: nil, max_tokens: 4096, temperature: 0.7)\n model_to_use = model || @default_model\n log_request(model_to_use, temperature, max_tokens, messages, tools)\n\n response = call_provider(model_to_use, messages, tools, temperature)\n log_response(response)\n\n LLMResponse.new(content: response.content, tool_calls: convert_tool_calls(response), finish_reason: 'stop')\n rescue StandardError => e\n @logger.error \"Error calling LLM: #{e.message}\"\n @logger.error e.backtrace.join(\"\\n\")\n\n LLMResponse.new(content: \"Error calling LLM: #{e.message}\", tool_calls: [], finish_reason: 'error')\n end\n\n private\n\n # Resolve the model and invoke the RubyLLM provider API directly.\n # @param model_id [String] model identifier\n # @param messages [Array] message history\n # @param tools [Array, nil] available tools\n # @param temperature [Float] sampling temperature\n # @return [RubyLLM::Message] provider response\n def call_provider(model_id, messages, tools, temperature)\n model_obj, provider = RubyLLM::Models.resolve(model_id, config: RubyLLM.config)\n\n provider.complete(\n build_rubyllm_messages(messages),\n tools: build_tools_hash(tools),\n temperature: temperature,\n model: model_obj,\n params: {},\n headers: {},\n schema: nil,\n thinking: nil\n )\n end\n\n # Log the outgoing LLM request details at debug level.\n def log_request(model, temperature, max_tokens, messages, tools)\n @logger.debug '=== LLM REQUEST ==='\n @logger.debug \"Model: #{model} | Temperature: #{temperature} | Max tokens: #{max_tokens}\"\n @logger.debug \"Messages: #{messages.length} total\"\n\n messages.each_with_index do |msg, i|\n log_message(msg, i)\n end\n\n log_tools(tools)\n end\n\n # Log a single message's role, content, and any tool calls.\n def log_message(msg, index)\n role = msg[:role]\n content = msg[:content]\n @logger.debug \" [#{index}] role=#{role} content_length=#{content&.length || 0}\"\n if role == 'system'\n @logger.debug \" [#{index}] system prompt (first 500 chars): #{content&.slice(0, 500)}\"\n else\n @logger.debug \" [#{index}] content: #{content}\"\n end\n msg[:tool_calls]&.each do |tc|\n func = tc[:function] || tc['function']\n name = func[:name] || func['name']\n args = func[:arguments] || func['arguments']\n @logger.debug \" [#{index}] tool_call: #{name}(#{args})\"\n end\n @logger.debug \" [#{index}] tool_call_id=#{msg[:tool_call_id]}\" if msg[:tool_call_id]\n end\n\n # Log available tool names and descriptions.\n def log_tools(tools)\n return unless tools && !tools.empty?\n\n @logger.debug \"Tools (#{tools.length}):\"\n tools.each do |tool|\n tool_name = tool.respond_to?(:name) ? tool.name : tool[:name] || tool.dig(:function, :name)\n tool_desc = tool.respond_to?(:description) ? tool.description : nil\n @logger.debug \" - #{tool_name}#{\": #{tool_desc.slice(0, 120)}\" if tool_desc}\"\n end\n end\n\n # Log the LLM response content and tool calls.\n def log_response(response)\n @logger.debug '=== LLM RESPONSE ==='\n @logger.debug \"Content length: #{response.content&.length || 0}\"\n @logger.debug \"Content: #{response.content}\"\n if response.tool_call?\n @logger.debug \"Tool calls: #{response.tool_calls.length}\"\n response.tool_calls.each_value do |tc|\n @logger.debug \" tool_call id=#{tc.id} name=#{tc.name} args=#{tc.arguments}\"\n end\n else\n @logger.debug 'Tool calls: none'\n end\n @logger.debug '=== END LLM RESPONSE ==='\n end\n\n # Convert internal message hashes to RubyLLM::Message objects.\n # @param messages [Array] message hashes with :role, :content, :tool_calls, :tool_call_id\n # @return [Array]\n def build_rubyllm_messages(messages)\n messages.map do |msg|\n RubyLLM::Message.new(\n role: msg[:role].to_sym,\n content: msg[:content] || '',\n tool_calls: convert_tool_calls_to_rubyllm(msg[:tool_calls]),\n tool_call_id: msg[:tool_call_id]\n )\n end\n end\n\n # Convert an array of tool instances to a hash keyed by tool name.\n # @param tools [Array, nil] tool instances\n # @return [Hash{Symbol => RubyLLM::Tool}]\n def build_tools_hash(tools)\n tools_hash = {}\n if tools && !tools.empty?\n tools.each do |tool|\n tools_hash[tool.name.to_sym] = tool\n end\n end\n tools_hash\n end\n\n # Configure the RubyLLM client with the appropriate API key and base URL for the provider.\n def configure_client\n RubyLLM.configure do |config|\n key_attr = PROVIDER_KEY_MAP[@provider]\n config.send(:\"#{key_attr}=\", @api_key) if key_attr && @api_key\n\n base_attr = PROVIDER_BASE_MAP[@provider]\n config.send(:\"#{base_attr}=\", @api_base) if base_attr && @api_base\n end\n\n @logger.debug \"Configured RubyLLM for provider: #{@provider}\"\n end\n\n # Convert RubyLLM tool calls to our ToolCallRequest format.\n # @param response [RubyLLM::Message] LLM response\n # @return [Array]\n def convert_tool_calls(response)\n return [] unless response.tool_call?\n\n response.tool_calls.map do |_id, tool_call|\n ToolCallRequest.new(\n id: tool_call.id,\n name: tool_call.name,\n arguments: tool_call.arguments,\n thought_signature: tool_call.respond_to?(:thought_signature) ? tool_call.thought_signature : nil\n )\n end\n end\n\n # Convert our tool call format to RubyLLM format for history replay.\n # @param tool_calls [Array, nil] tool calls in OpenAI-style format\n # @return [Hash{String => ToolCallProxy}, nil] tool calls keyed by ID\n def convert_tool_calls_to_rubyllm(tool_calls)\n return nil unless tool_calls && !tool_calls.empty?\n\n # Convert array of tool calls to hash keyed by ID\n tool_calls.each_with_object({}) do |tc, hash|\n func = tc['function'] || tc[:function]\n args = func['arguments'] || func[:arguments]\n\n # Parse arguments if they're a JSON string\n require 'json'\n parsed_args = args.is_a?(String) ? JSON.parse(args) : args\n\n # Create a simple object to represent the tool call\n tool_call_id = tc['id'] || tc[:id]\n tool_call_obj = ToolCallProxy.new(\n tool_call_id,\n func['name'] || func[:name],\n parsed_args\n )\n\n hash[tool_call_id] = tool_call_obj\n end\n rescue JSON::ParserError\n nil\n end\n end\n end\nend\n"}, "aa94b0735c1deff28ae8d736ea8f747f00afd202": {"t": 1785756131.3683503, "n": 8946, "h": "a9f1fb9211d19de1", "c": "# frozen_string_literal: true\n\nrequire 'ruby_llm'\nrequire 'logger'\nrequire_relative 'base'\n\nmodule Nanobot\n module Providers\n # LLM provider implementation backed by the RubyLLM gem.\n # Supports Anthropic, OpenAI, DeepSeek, Groq, and OpenRouter backends.\n class RubyLLMProvider < LLMProvider\n # Lightweight struct for replaying tool calls through RubyLLM message history.\n ToolCallProxy = Struct.new(:id, :name, :arguments)\n\n attr_reader :logger, :default_model\n\n # @param api_key [String, nil] API key for the provider\n # @param api_base [String, nil] custom API base URL\n # @param default_model [String, nil] model identifier (defaults to claude-haiku-4-5)\n # @param provider [String] provider backend name (anthropic, openai, deepseek, groq, openrouter)\n # @param logger [Logger, nil] logger instance (defaults to null logger)\n def initialize(api_key: nil, api_base: nil, default_model: nil, provider: 'anthropic', logger: nil)\n super()\n @api_key = api_key\n @api_base = api_base\n @provider = provider.to_s\n @default_model = default_model || 'claude-haiku-4-5'\n @logger = logger || Logger.new(IO::NULL)\n @chat = nil\n\n configure_client\n end\n\n # Chat with tools using RubyLLM's provider API directly\n # This bypasses RubyLLM's automatic tool execution to give us control\n # @param messages [Array] message history with system, user, assistant roles\n # @param tools [Array] array of tool instances\n # @param model [String, nil] model to use\n # @param max_tokens [Integer] max tokens (not used currently)\n # @param temperature [Float] sampling temperature\n # @return [LLMResponse] response with content and tool calls\n def chat(messages:, tools: nil, model: nil, max_tokens: 4096, temperature: 0.7)\n model_to_use = model || @default_model\n log_request(model_to_use, temperature, max_tokens, messages, tools)\n\n response = call_provider(model_to_use, messages, tools, temperature)\n log_response(response)\n\n LLMResponse.new(content: response.content, tool_calls: convert_tool_calls(response), finish_reason: 'stop')\n rescue StandardError => e\n @logger.error \"Error calling LLM: #{e.message}\"\n @logger.error e.backtrace.join(\"\\n\")\n\n LLMResponse.new(content: \"Error calling LLM: #{e.message}\", tool_calls: [], finish_reason: 'error')\n end\n\n private\n\n # Resolve the model and invoke the RubyLLM provider API directly.\n # @param model_id [String] model identifier\n # @param messages [Array] message history\n # @param tools [Array, nil] available tools\n # @param temperature [Float] sampling temperature\n # @return [RubyLLM::Message] provider response\n def call_provider(model_id, messages, tools, temperature)\n model_obj, provider = RubyLLM::Models.resolve(model_id, config: RubyLLM.config)\n\n provider.complete(\n build_rubyllm_messages(messages),\n tools: build_tools_hash(tools),\n temperature: temperature,\n model: model_obj,\n params: {},\n headers: {},\n schema: nil,\n thinking: nil\n )\n end\n\n # Log the outgoing LLM request details at debug level.\n def log_request(model, temperature, max_tokens, messages, tools)\n @logger.debug '=== LLM REQUEST ==='\n @logger.debug \"Model: #{model} | Temperature: #{temperature} | Max tokens: #{max_tokens}\"\n @logger.debug \"Messages: #{messages.length} total\"\n\n messages.each_with_index do |msg, i|\n log_message(msg, i)\n end\n\n log_tools(tools)\n end\n\n # Log a single message's role, content, and any tool calls.\n def log_message(msg, index)\n role = msg[:role]\n content = msg[:content]\n @logger.debug \" [#{index}] role=#{role} content_length=#{content&.length || 0}\"\n if role == 'system'\n @logger.debug \" [#{index}] system prompt (first 500 chars): #{content&.slice(0, 500)}\"\n else\n @logger.debug \" [#{index}] content: #{content}\"\n end\n msg[:tool_calls]&.each do |tc|\n func = tc[:function] || tc['function']\n name = func[:name] || func['name']\n args = func[:arguments] || func['arguments']\n @logger.debug \" [#{index}] tool_call: #{name}(#{args})\"\n end\n @logger.debug \" [#{index}] tool_call_id=#{msg[:tool_call_id]}\" if msg[:tool_call_id]\n end\n\n # Log available tool names and descriptions.\n def log_tools(tools)\n return unless tools && !tools.empty?\n\n @logger.debug \"Tools (#{tools.length}):\"\n tools.each do |tool|\n tool_name = tool.respond_to?(:name) ? tool.name : tool[:name] || tool.dig(:function, :name)\n tool_desc = tool.respond_to?(:description) ? tool.description : nil\n @logger.debug \" - #{tool_name}#{\": #{tool_desc.slice(0, 120)}\" if tool_desc}\"\n end\n end\n\n # Log the LLM response content and tool calls.\n def log_response(response)\n @logger.debug '=== LLM RESPONSE ==='\n @logger.debug \"Content length: #{response.content&.length || 0}\"\n @logger.debug \"Content: #{response.content}\"\n if response.tool_call?\n @logger.debug \"Tool calls: #{response.tool_calls.length}\"\n response.tool_calls.each_value do |tc|\n @logger.debug \" tool_call id=#{tc.id} name=#{tc.name} args=#{tc.arguments}\"\n end\n else\n @logger.debug 'Tool calls: none'\n end\n @logger.debug '=== END LLM RESPONSE ==='\n end\n\n # Convert internal message hashes to RubyLLM::Message objects.\n # @param messages [Array] message hashes with :role, :content, :tool_calls, :tool_call_id\n # @return [Array]\n def build_rubyllm_messages(messages)\n messages.map do |msg|\n RubyLLM::Message.new(\n role: msg[:role].to_sym,\n content: msg[:content] || '',\n tool_calls: convert_tool_calls_to_rubyllm(msg[:tool_calls]),\n tool_call_id: msg[:tool_call_id]\n )\n end\n end\n\n # Convert an array of tool instances to a hash keyed by tool name.\n # @param tools [Array, nil] tool instances\n # @return [Hash{Symbol => RubyLLM::Tool}]\n def build_tools_hash(tools)\n tools_hash = {}\n if tools && !tools.empty?\n tools.each do |tool|\n tools_hash[tool.name.to_sym] = tool\n end\n end\n tools_hash\n end\n\n # Configure the RubyLLM client with the appropriate API key for the provider.\n def configure_client\n return unless @api_key\n\n RubyLLM.configure do |config|\n case @provider\n when 'anthropic'\n config.anthropic_api_key = @api_key\n when 'openai', 'deepseek', 'groq'\n config.openai_api_key = @api_key\n when 'openrouter'\n config.openrouter_api_key = @api_key\n else\n @logger.warn \"Unknown provider '#{@provider}', defaulting to OpenAI-compatible config\"\n config.openai_api_key = @api_key\n end\n end\n\n @logger.debug \"Configured RubyLLM for provider: #{@provider}\"\n end\n\n # Convert RubyLLM tool calls to our ToolCallRequest format.\n # @param response [RubyLLM::Message] LLM response\n # @return [Array]\n def convert_tool_calls(response)\n return [] unless response.tool_call?\n\n response.tool_calls.map do |_id, tool_call|\n ToolCallRequest.new(\n id: tool_call.id,\n name: tool_call.name,\n arguments: tool_call.arguments\n )\n end\n end\n\n # Convert our tool call format to RubyLLM format for history replay.\n # @param tool_calls [Array, nil] tool calls in OpenAI-style format\n # @return [Hash{String => ToolCallProxy}, nil] tool calls keyed by ID\n def convert_tool_calls_to_rubyllm(tool_calls)\n return nil unless tool_calls && !tool_calls.empty?\n\n # Convert array of tool calls to hash keyed by ID\n tool_calls.each_with_object({}) do |tc, hash|\n func = tc['function'] || tc[:function]\n args = func['arguments'] || func[:arguments]\n\n # Parse arguments if they're a JSON string\n require 'json'\n parsed_args = args.is_a?(String) ? JSON.parse(args) : args\n\n # Create a simple object to represent the tool call\n tool_call_id = tc['id'] || tc[:id]\n tool_call_obj = ToolCallProxy.new(\n tool_call_id,\n func['name'] || func[:name],\n parsed_args\n )\n\n hash[tool_call_id] = tool_call_obj\n end\n rescue JSON::ParserError\n nil\n end\n end\n end\nend\n"}, "81fb71999fd90575696b31b9b3b7077086f2342a": {"t": 1785756132.6695137, "n": 7002, "h": "1fbb7604e50550b8", "c": "# frozen_string_literal: true\n\nrequire 'ruby_llm'\nrequire 'logger'\nrequire_relative 'base'\n\nmodule Nanobot\n module Providers\n class RubyLLMProvider < LLMProvider\n ToolCallProxy = Struct.new(:id, :name, :arguments)\n\n attr_reader :logger, :default_model\n\n def initialize(api_key: nil, api_base: nil, default_model: nil, provider: 'anthropic', logger: nil)\n super()\n @api_key = api_key\n @api_base = api_base\n @provider = provider.to_s\n @default_model = default_model || 'claude-haiku-4-5'\n @logger = logger || Logger.new(IO::NULL)\n @chat = nil\n\n configure_client\n end\n\n # Chat with tools using RubyLLM's provider API directly\n # This bypasses RubyLLM's automatic tool execution to give us control\n # @param messages [Array] message history with system, user, assistant roles\n # @param tools [Array] array of tool instances\n # @param model [String, nil] model to use\n # @param max_tokens [Integer] max tokens (not used currently)\n # @param temperature [Float] sampling temperature\n # @return [LLMResponse] response with content and tool calls\n def chat(messages:, tools: nil, model: nil, max_tokens: 4096, temperature: 0.7)\n model_to_use = model || @default_model\n log_request(model_to_use, temperature, max_tokens, messages, tools)\n\n response = call_provider(model_to_use, messages, tools, temperature)\n log_response(response)\n\n LLMResponse.new(content: response.content, tool_calls: convert_tool_calls(response), finish_reason: 'stop')\n rescue StandardError => e\n @logger.error \"Error calling LLM: #{e.message}\"\n @logger.error e.backtrace.join(\"\\n\")\n\n LLMResponse.new(content: \"Error calling LLM: #{e.message}\", tool_calls: [], finish_reason: 'error')\n end\n\n private\n\n def call_provider(model_id, messages, tools, temperature)\n model_obj, provider = RubyLLM::Models.resolve(model_id, config: RubyLLM.config)\n\n provider.complete(\n build_rubyllm_messages(messages),\n tools: build_tools_hash(tools),\n temperature: temperature,\n model: model_obj,\n params: {},\n headers: {},\n schema: nil,\n thinking: nil\n )\n end\n\n def log_request(model, temperature, max_tokens, messages, tools)\n @logger.debug '=== LLM REQUEST ==='\n @logger.debug \"Model: #{model} | Temperature: #{temperature} | Max tokens: #{max_tokens}\"\n @logger.debug \"Messages: #{messages.length} total\"\n\n messages.each_with_index do |msg, i|\n log_message(msg, i)\n end\n\n log_tools(tools)\n end\n\n def log_message(msg, index)\n role = msg[:role]\n content = msg[:content]\n @logger.debug \" [#{index}] role=#{role} content_length=#{content&.length || 0}\"\n if role == 'system'\n @logger.debug \" [#{index}] system prompt (first 500 chars): #{content&.slice(0, 500)}\"\n else\n @logger.debug \" [#{index}] content: #{content}\"\n end\n msg[:tool_calls]&.each do |tc|\n func = tc[:function] || tc['function']\n name = func[:name] || func['name']\n args = func[:arguments] || func['arguments']\n @logger.debug \" [#{index}] tool_call: #{name}(#{args})\"\n end\n @logger.debug \" [#{index}] tool_call_id=#{msg[:tool_call_id]}\" if msg[:tool_call_id]\n end\n\n def log_tools(tools)\n return unless tools && !tools.empty?\n\n @logger.debug \"Tools (#{tools.length}):\"\n tools.each do |tool|\n tool_name = tool.respond_to?(:name) ? tool.name : tool[:name] || tool.dig(:function, :name)\n tool_desc = tool.respond_to?(:description) ? tool.description : nil\n @logger.debug \" - #{tool_name}#{\": #{tool_desc.slice(0, 120)}\" if tool_desc}\"\n end\n end\n\n def log_response(response)\n @logger.debug '=== LLM RESPONSE ==='\n @logger.debug \"Content length: #{response.content&.length || 0}\"\n @logger.debug \"Content: #{response.content}\"\n if response.tool_call?\n @logger.debug \"Tool calls: #{response.tool_calls.length}\"\n response.tool_calls.each_value do |tc|\n @logger.debug \" tool_call id=#{tc.id} name=#{tc.name} args=#{tc.arguments}\"\n end\n else\n @logger.debug 'Tool calls: none'\n end\n @logger.debug '=== END LLM RESPONSE ==='\n end\n\n def build_rubyllm_messages(messages)\n messages.map do |msg|\n RubyLLM::Message.new(\n role: msg[:role].to_sym,\n content: msg[:content] || '',\n tool_calls: convert_tool_calls_to_rubyllm(msg[:tool_calls]),\n tool_call_id: msg[:tool_call_id]\n )\n end\n end\n\n def build_tools_hash(tools)\n tools_hash = {}\n if tools && !tools.empty?\n tools.each do |tool|\n tools_hash[tool.name.to_sym] = tool\n end\n end\n tools_hash\n end\n\n def configure_client\n return unless @api_key\n\n RubyLLM.configure do |config|\n case @provider\n when 'anthropic'\n config.anthropic_api_key = @api_key\n when 'openai', 'deepseek', 'groq'\n config.openai_api_key = @api_key\n when 'openrouter'\n config.openrouter_api_key = @api_key\n else\n @logger.warn \"Unknown provider '#{@provider}', defaulting to OpenAI-compatible config\"\n config.openai_api_key = @api_key\n end\n end\n\n @logger.debug \"Configured RubyLLM for provider: #{@provider}\"\n end\n\n # Convert RubyLLM tool calls to our format\n def convert_tool_calls(response)\n return [] unless response.tool_call?\n\n response.tool_calls.map do |_id, tool_call|\n ToolCallRequest.new(\n id: tool_call.id,\n name: tool_call.name,\n arguments: tool_call.arguments\n )\n end\n end\n\n # Convert our tool call format to RubyLLM format (for history replay)\n def convert_tool_calls_to_rubyllm(tool_calls)\n return nil unless tool_calls && !tool_calls.empty?\n\n # Convert array of tool calls to hash keyed by ID\n tool_calls.each_with_object({}) do |tc, hash|\n func = tc['function'] || tc[:function]\n args = func['arguments'] || func[:arguments]\n\n # Parse arguments if they're a JSON string\n require 'json'\n parsed_args = args.is_a?(String) ? JSON.parse(args) : args\n\n # Create a simple object to represent the tool call\n tool_call_id = tc['id'] || tc[:id]\n tool_call_obj = ToolCallProxy.new(\n tool_call_id,\n func['name'] || func[:name],\n parsed_args\n )\n\n hash[tool_call_id] = tool_call_obj\n end\n rescue JSON::ParserError\n nil\n end\n end\n end\nend\n"}, "82aa956a8cff7d52d96a233af1b74dad87663ed4": {"t": 1785756134.1161559, "n": 7194, "h": "2f619faa17d72016", "c": "# frozen_string_literal: true\n\nrequire 'ruby_llm'\nrequire 'logger'\nrequire_relative 'base'\n\nmodule Nanobot\n module Providers\n class RubyLLMProvider < LLMProvider\n ToolCallProxy = Struct.new(:id, :name, :arguments)\n\n attr_reader :logger, :default_model\n\n def initialize(api_key: nil, api_base: nil, default_model: nil, provider: 'anthropic', logger: nil)\n super()\n @api_key = api_key\n @api_base = api_base\n @provider = provider.to_s\n @default_model = default_model || 'claude-haiku-4-5'\n @logger = logger || Logger.new(IO::NULL)\n @chat = nil\n\n configure_client\n end\n\n # Chat with tools using RubyLLM's provider API directly\n # This bypasses RubyLLM's automatic tool execution to give us control\n # @param messages [Array] message history with system, user, assistant roles\n # @param tools [Array] array of tool instances\n # @param model [String, nil] model to use\n # @param max_tokens [Integer] max tokens (not used currently)\n # @param temperature [Float] sampling temperature\n # @return [LLMResponse] response with content and tool calls\n # rubocop:disable Metrics/MethodLength\n def chat(messages:, tools: nil, model: nil, max_tokens: 4096, temperature: 0.7)\n model_to_use = model || @default_model\n\n log_request(model_to_use, temperature, max_tokens, messages, tools)\n\n begin\n model_obj, provider = RubyLLM::Models.resolve(model_to_use, config: RubyLLM.config)\n ruby_llm_messages = build_rubyllm_messages(messages)\n tools_hash = build_tools_hash(tools)\n\n response = provider.complete(\n ruby_llm_messages,\n tools: tools_hash,\n temperature: temperature,\n model: model_obj,\n params: {},\n headers: {},\n schema: nil,\n thinking: nil\n )\n\n log_response(response)\n\n LLMResponse.new(\n content: response.content,\n tool_calls: convert_tool_calls(response),\n finish_reason: 'stop'\n )\n rescue StandardError => e\n @logger.error \"Error calling LLM: #{e.message}\"\n @logger.error e.backtrace.join(\"\\n\")\n\n LLMResponse.new(\n content: \"Error calling LLM: #{e.message}\",\n tool_calls: [],\n finish_reason: 'error'\n )\n end\n end\n # rubocop:enable Metrics/MethodLength\n\n private\n\n def log_request(model, temperature, max_tokens, messages, tools)\n @logger.debug '=== LLM REQUEST ==='\n @logger.debug \"Model: #{model} | Temperature: #{temperature} | Max tokens: #{max_tokens}\"\n @logger.debug \"Messages: #{messages.length} total\"\n\n messages.each_with_index do |msg, i|\n log_message(msg, i)\n end\n\n log_tools(tools)\n end\n\n def log_message(msg, index)\n role = msg[:role]\n content = msg[:content]\n @logger.debug \" [#{index}] role=#{role} content_length=#{content&.length || 0}\"\n if role == 'system'\n @logger.debug \" [#{index}] system prompt (first 500 chars): #{content&.slice(0, 500)}\"\n else\n @logger.debug \" [#{index}] content: #{content}\"\n end\n msg[:tool_calls]&.each do |tc|\n func = tc[:function] || tc['function']\n name = func[:name] || func['name']\n args = func[:arguments] || func['arguments']\n @logger.debug \" [#{index}] tool_call: #{name}(#{args})\"\n end\n @logger.debug \" [#{index}] tool_call_id=#{msg[:tool_call_id]}\" if msg[:tool_call_id]\n end\n\n def log_tools(tools)\n return unless tools && !tools.empty?\n\n @logger.debug \"Tools (#{tools.length}):\"\n tools.each do |tool|\n tool_name = tool.respond_to?(:name) ? tool.name : tool[:name] || tool.dig(:function, :name)\n tool_desc = tool.respond_to?(:description) ? tool.description : nil\n @logger.debug \" - #{tool_name}#{\": #{tool_desc.slice(0, 120)}\" if tool_desc}\"\n end\n end\n\n def log_response(response)\n @logger.debug '=== LLM RESPONSE ==='\n @logger.debug \"Content length: #{response.content&.length || 0}\"\n @logger.debug \"Content: #{response.content}\"\n if response.tool_call?\n @logger.debug \"Tool calls: #{response.tool_calls.length}\"\n response.tool_calls.each_value do |tc|\n @logger.debug \" tool_call id=#{tc.id} name=#{tc.name} args=#{tc.arguments}\"\n end\n else\n @logger.debug 'Tool calls: none'\n end\n @logger.debug '=== END LLM RESPONSE ==='\n end\n\n def build_rubyllm_messages(messages)\n messages.map do |msg|\n RubyLLM::Message.new(\n role: msg[:role].to_sym,\n content: msg[:content] || '',\n tool_calls: convert_tool_calls_to_rubyllm(msg[:tool_calls]),\n tool_call_id: msg[:tool_call_id]\n )\n end\n end\n\n def build_tools_hash(tools)\n tools_hash = {}\n if tools && !tools.empty?\n tools.each do |tool|\n tools_hash[tool.name.to_sym] = tool\n end\n end\n tools_hash\n end\n\n def configure_client\n return unless @api_key\n\n RubyLLM.configure do |config|\n case @provider\n when 'anthropic'\n config.anthropic_api_key = @api_key\n when 'openai', 'deepseek', 'groq'\n config.openai_api_key = @api_key\n when 'openrouter'\n config.openrouter_api_key = @api_key\n else\n @logger.warn \"Unknown provider '#{@provider}', defaulting to OpenAI-compatible config\"\n config.openai_api_key = @api_key\n end\n end\n\n @logger.debug \"Configured RubyLLM for provider: #{@provider}\"\n end\n\n # Convert RubyLLM tool calls to our format\n def convert_tool_calls(response)\n return [] unless response.tool_call?\n\n response.tool_calls.map do |_id, tool_call|\n ToolCallRequest.new(\n id: tool_call.id,\n name: tool_call.name,\n arguments: tool_call.arguments\n )\n end\n end\n\n # Convert our tool call format to RubyLLM format (for history replay)\n def convert_tool_calls_to_rubyllm(tool_calls)\n return nil unless tool_calls && !tool_calls.empty?\n\n # Convert array of tool calls to hash keyed by ID\n tool_calls.each_with_object({}) do |tc, hash|\n func = tc['function'] || tc[:function]\n args = func['arguments'] || func[:arguments]\n\n # Parse arguments if they're a JSON string\n require 'json'\n parsed_args = args.is_a?(String) ? JSON.parse(args) : args\n\n # Create a simple object to represent the tool call\n tool_call_id = tc['id'] || tc[:id]\n tool_call_obj = ToolCallProxy.new(\n tool_call_id,\n func['name'] || func[:name],\n parsed_args\n )\n\n hash[tool_call_id] = tool_call_obj\n end\n rescue JSON::ParserError\n nil\n end\n end\n end\nend\n"}}}}