From 18025f94e6914b6bec05286f5c0311f689bff39d Mon Sep 17 00:00:00 2001 From: Sylvain Utard Date: Wed, 23 Jul 2025 09:30:14 +0200 Subject: [PATCH 1/2] Introduce support of Responses API Some model (incl. `o4-mini-deep-research`) aren't compatible with the chat/completion API. This PR introduces a new `Response` class, which - similarly to `Chat` (and inheriting from the same base `Conversation` class) - allows a user to target the `responses` endpoint. --- lib/ruby_llm.rb | 4 + lib/ruby_llm/active_record/acts_as.rb | 2 +- lib/ruby_llm/chat.rb | 153 +----- lib/ruby_llm/conversation.rb | 160 +++++++ lib/ruby_llm/provider.rb | 34 +- lib/ruby_llm/providers/anthropic/chat.rb | 2 +- lib/ruby_llm/providers/anthropic/streaming.rb | 6 +- lib/ruby_llm/providers/bedrock/chat.rb | 6 +- .../providers/bedrock/streaming/base.rb | 4 +- lib/ruby_llm/providers/gemini/chat.rb | 4 +- lib/ruby_llm/providers/gemini/streaming.rb | 2 +- lib/ruby_llm/providers/openai.rb | 2 + lib/ruby_llm/providers/openai/chat.rb | 86 +++- lib/ruby_llm/providers/openai/response.rb | 43 ++ lib/ruby_llm/providers/openai/streaming.rb | 6 +- lib/ruby_llm/providers/openai/tools.rb | 39 +- lib/ruby_llm/response.rb | 22 + lib/ruby_llm/streaming.rb | 2 +- ...mpty_assistant_messages_on_api_failure.yml | 115 +++++ ...enai_o4-mini-deep-research_can_respond.yml | 446 ++++++++++++++++++ .../response_tool_calling_can_use_tools.yml | 304 ++++++++++++ spec/ruby_llm/active_record/acts_as_spec.rb | 2 +- spec/ruby_llm/chat_request_options_spec.rb | 4 +- .../providers/anthropic/tools_spec.rb | 6 +- spec/ruby_llm/response_spec.rb | 48 ++ spec/spec_helper.rb | 4 + 26 files changed, 1326 insertions(+), 180 deletions(-) create mode 100644 lib/ruby_llm/conversation.rb create mode 100644 lib/ruby_llm/providers/openai/response.rb create mode 100644 lib/ruby_llm/response.rb create mode 100644 spec/fixtures/vcr_cassettes/activerecord_actsas_error_handling_destroys_empty_assistant_messages_on_api_failure.yml create mode 100644 spec/fixtures/vcr_cassettes/response_basic_response_functionality_openai_o4-mini-deep-research_can_respond.yml create mode 100644 spec/fixtures/vcr_cassettes/response_tool_calling_can_use_tools.yml create mode 100644 spec/ruby_llm/response_spec.rb diff --git a/lib/ruby_llm.rb b/lib/ruby_llm.rb index 1817ddc45..9982ac05f 100644 --- a/lib/ruby_llm.rb +++ b/lib/ruby_llm.rb @@ -43,6 +43,10 @@ def chat(...) Chat.new(...) end + def response(...) + Response.new(...) + end + def embed(...) Embedding.embed(...) end diff --git a/lib/ruby_llm/active_record/acts_as.rb b/lib/ruby_llm/active_record/acts_as.rb index 25abfefa9..4e8288ae1 100644 --- a/lib/ruby_llm/active_record/acts_as.rb +++ b/lib/ruby_llm/active_record/acts_as.rb @@ -159,7 +159,7 @@ def ask(message, with: nil, &) alias say ask def complete(...) - to_llm.complete(...) + to_llm.process(...) rescue RubyLLM::Error => e if @message&.persisted? && @message.content.blank? RubyLLM.logger.debug "RubyLLM: API call failed, destroying message: #{@message.id}" diff --git a/lib/ruby_llm/chat.rb b/lib/ruby_llm/chat.rb index f547887a7..d8d836208 100644 --- a/lib/ruby_llm/chat.rb +++ b/lib/ruby_llm/chat.rb @@ -8,162 +8,17 @@ module RubyLLM # chat = RubyLLM.chat # chat.ask "What's the best way to learn Ruby?" # chat.ask "Can you elaborate on that?" - class Chat - include Enumerable - - attr_reader :model, :messages, :tools, :params - - def initialize(model: nil, provider: nil, assume_model_exists: false, context: nil) - if assume_model_exists && !provider - raise ArgumentError, 'Provider must be specified if assume_model_exists is true' - end - - @context = context - @config = context&.config || RubyLLM.config - model_id = model || @config.default_model - with_model(model_id, provider: provider, assume_exists: assume_model_exists) - @temperature = 0.7 - @messages = [] - @tools = {} - @params = {} - @on = { - new_message: nil, - end_message: nil - } - end - - def ask(message = nil, with: nil, &) - add_message role: :user, content: Content.new(message, with) - complete(&) - end - - alias say ask - - def with_instructions(instructions, replace: false) - @messages = @messages.reject { |msg| msg.role == :system } if replace - - add_message role: :system, content: instructions - self - end - - def with_tool(tool) - unless @model.supports_functions? - raise UnsupportedFunctionsError, "Model #{@model.id} doesn't support function calling" - end - - tool_instance = tool.is_a?(Class) ? tool.new : tool - @tools[tool_instance.name.to_sym] = tool_instance - self - end - - def with_tools(*tools) - tools.each { |tool| with_tool tool } - self - end - - def with_model(model_id, provider: nil, assume_exists: false) - @model, @provider = Models.resolve(model_id, provider:, assume_exists:) - @connection = @context ? @context.connection_for(@provider) : @provider.connection(@config) - self - end - - def with_temperature(temperature) - @temperature = temperature - self - end - - def with_context(context) - @context = context - @config = context.config - with_model(@model.id, provider: @provider.slug, assume_exists: true) - self - end - - def with_params(**params) - @params = params - self - end - - def on_new_message(&block) - @on[:new_message] = block - self - end - - def on_end_message(&block) - @on[:end_message] = block - self - end - - def each(&) - messages.each(&) - end - - def complete(&) - response = @provider.complete( + class Chat < Conversation + def get_response(&) + @provider.complete( messages, tools: @tools, temperature: @temperature, model: @model.id, connection: @connection, params: @params, - &wrap_streaming_block(&) + & ) - - @on[:new_message]&.call unless block_given? - add_message response - @on[:end_message]&.call(response) - - if response.tool_call? - handle_tool_calls(response, &) - else - response - end - end - - def add_message(message_or_attributes) - message = message_or_attributes.is_a?(Message) ? message_or_attributes : Message.new(message_or_attributes) - messages << message - message - end - - def reset_messages! - @messages.clear - end - - private - - def wrap_streaming_block(&block) - return nil unless block_given? - - first_chunk_received = false - - proc do |chunk| - # Create message on first content chunk - unless first_chunk_received - first_chunk_received = true - @on[:new_message]&.call - end - - # Pass chunk to user's block - block.call chunk - end - end - - def handle_tool_calls(response, &) - response.tool_calls.each_value do |tool_call| - @on[:new_message]&.call - result = execute_tool tool_call - message = add_message role: :tool, content: result.to_s, tool_call_id: tool_call.id - @on[:end_message]&.call(message) - end - - complete(&) - end - - def execute_tool(tool_call) - tool = tools[tool_call.name.to_sym] - args = tool_call.arguments - tool.call(args) end end end diff --git a/lib/ruby_llm/conversation.rb b/lib/ruby_llm/conversation.rb new file mode 100644 index 000000000..8f524aa35 --- /dev/null +++ b/lib/ruby_llm/conversation.rb @@ -0,0 +1,160 @@ +# frozen_string_literal: true + +module RubyLLM + # Represents a base class for conversations with an AI model. Handles tool integrations. + # + # Example: + # conversation = RubyLLM.conversation + # conversation.ask "What's the best way to learn Ruby?" + # conversation.ask "Can you elaborate on that?" + class Conversation + include Enumerable + + attr_reader :model, :messages, :tools, :params + + def initialize(model: nil, provider: nil, assume_model_exists: false, context: nil) + if assume_model_exists && !provider + raise ArgumentError, 'Provider must be specified if assume_model_exists is true' + end + + @context = context + @config = context&.config || RubyLLM.config + model_id = model || @config.default_model + with_model(model_id, provider: provider, assume_exists: assume_model_exists) + @temperature = 0.7 + @messages = [] + @tools = {} + @params = {} + @on = { + new_message: nil, + end_message: nil + } + end + + def ask(message = nil, with: nil, &) + add_message role: :user, content: Content.new(message, with) + process(&) + end + + alias say ask + + def with_instructions(instructions, replace: false) + @messages = @messages.reject { |msg| msg.role == :system } if replace + + add_message role: :system, content: instructions + self + end + + def with_tool(tool) + unless @model.supports_functions? + raise UnsupportedFunctionsError, "Model #{@model.id} doesn't support function calling" + end + + tool_instance = tool.is_a?(Class) ? tool.new : tool + @tools[tool_instance.name.to_sym] = tool_instance + self + end + + def with_tools(*tools) + tools.each { |tool| with_tool tool } + self + end + + def with_model(model_id, provider: nil, assume_exists: false) + @model, @provider = Models.resolve(model_id, provider:, assume_exists:) + @connection = @context ? @context.connection_for(@provider) : @provider.connection(@config) + self + end + + def with_temperature(temperature) + @temperature = temperature + self + end + + def with_context(context) + @context = context + @config = context.config + with_model(@model.id, provider: @provider.slug, assume_exists: true) + self + end + + def with_params(**params) + @params = params + self + end + + def on_new_message(&block) + @on[:new_message] = block + self + end + + def on_end_message(&block) + @on[:end_message] = block + self + end + + def each(&) + messages.each(&) + end + + def process(&) + response = get_response(&wrap_streaming_block(&)) + + @on[:new_message]&.call unless block_given? + add_message response + @on[:end_message]&.call(response) + + if response.tool_call? + handle_tool_calls(response, &) + else + response + end + end + + def add_message(message_or_attributes) + message = message_or_attributes.is_a?(Message) ? message_or_attributes : Message.new(message_or_attributes) + messages << message + message + end + + def reset_messages! + @messages.clear + end + + private + + def wrap_streaming_block(&block) + return nil unless block_given? + + first_chunk_received = false + + proc do |chunk| + # Create message on first content chunk + unless first_chunk_received + first_chunk_received = true + @on[:new_message]&.call + end + + # Pass chunk to user's block + block.call chunk + end + end + + def handle_tool_calls(response, &) + response.tool_calls.each_value do |tool_call| + @on[:new_message]&.call + result = execute_tool tool_call + message = add_message role: :tool, content: result.to_s, tool_call_id: tool_call.id + @on[:end_message]&.call(message) + end + + process(&) + end + + def execute_tool(tool_call) + tool = tools[tool_call.name.to_sym] + args = tool_call.arguments + tool.call(args) + end + end +end diff --git a/lib/ruby_llm/provider.rb b/lib/ruby_llm/provider.rb index 8899e43db..bc4f1523b 100644 --- a/lib/ruby_llm/provider.rb +++ b/lib/ruby_llm/provider.rb @@ -15,7 +15,7 @@ def complete(messages, tools:, temperature:, model:, connection:, params: {}, &) payload = deep_merge( params, - render_payload( + render_completion_payload( messages, tools: tools, temperature: normalized_temperature, @@ -25,9 +25,30 @@ def complete(messages, tools:, temperature:, model:, connection:, params: {}, &) ) if block_given? - stream_response connection, payload, & + stream_response connection, completion_stream_url, payload, & else - sync_response connection, payload + sync_completion_response connection, payload + end + end + + def respond(messages, tools:, temperature:, model:, connection:, params: {}, &) # rubocop:disable Metrics/ParameterLists + normalized_temperature = maybe_normalize_temperature(temperature, model) + + payload = deep_merge( + params, + render_response_payload( + messages, + tools: tools, + temperature: normalized_temperature, + model: model, + stream: block_given? + ) + ) + + if block_given? + stream_response connection, responses_stream_url, payload, & + else + sync_respond_response connection, payload end end @@ -88,10 +109,15 @@ def maybe_normalize_temperature(temperature, model) end end - def sync_response(connection, payload) + def sync_completion_response(connection, payload) response = connection.post completion_url, payload parse_completion_response response end + + def sync_respond_response(connection, payload) + response = connection.post responses_url, payload + parse_respond_response response + end end def try_parse_json(maybe_json) diff --git a/lib/ruby_llm/providers/anthropic/chat.rb b/lib/ruby_llm/providers/anthropic/chat.rb index 2ba96009d..c7cd0ac6e 100644 --- a/lib/ruby_llm/providers/anthropic/chat.rb +++ b/lib/ruby_llm/providers/anthropic/chat.rb @@ -11,7 +11,7 @@ def completion_url '/v1/messages' end - def render_payload(messages, tools:, temperature:, model:, stream: false) + def render_completion_payload(messages, tools:, temperature:, model:, stream: false) system_messages, chat_messages = separate_messages(messages) system_content = build_system_content(system_messages) diff --git a/lib/ruby_llm/providers/anthropic/streaming.rb b/lib/ruby_llm/providers/anthropic/streaming.rb index 3bf842150..360e98c93 100644 --- a/lib/ruby_llm/providers/anthropic/streaming.rb +++ b/lib/ruby_llm/providers/anthropic/streaming.rb @@ -7,10 +7,14 @@ module Anthropic module Streaming private - def stream_url + def completion_stream_url completion_url end + def responses_stream_url + responses_url + end + def build_chunk(data) Chunk.new( role: :assistant, diff --git a/lib/ruby_llm/providers/bedrock/chat.rb b/lib/ruby_llm/providers/bedrock/chat.rb index 742579558..3901bec79 100644 --- a/lib/ruby_llm/providers/bedrock/chat.rb +++ b/lib/ruby_llm/providers/bedrock/chat.rb @@ -7,7 +7,7 @@ module Bedrock module Chat module_function - def sync_response(connection, payload) + def sync_completion_response(connection, payload) signature = sign_request("#{connection.connection.url_prefix}#{completion_url}", config: connection.config, payload:) response = connection.post completion_url, payload do |req| @@ -39,8 +39,8 @@ def completion_url "model/#{@model_id}/invoke" end - def render_payload(messages, tools:, temperature:, model:, stream: false) # rubocop:disable Lint/UnusedMethodArgument - # Hold model_id in instance variable for use in completion_url and stream_url + def render_completion_payload(messages, tools:, temperature:, model:, stream: false) # rubocop:disable Lint/UnusedMethodArgument + # Hold model_id in instance variable for use in completion_url and completion_stream_url @model_id = model system_messages, chat_messages = Anthropic::Chat.separate_messages(messages) diff --git a/lib/ruby_llm/providers/bedrock/streaming/base.rb b/lib/ruby_llm/providers/bedrock/streaming/base.rb index 26860076e..683ef6ef0 100644 --- a/lib/ruby_llm/providers/bedrock/streaming/base.rb +++ b/lib/ruby_llm/providers/bedrock/streaming/base.rb @@ -25,11 +25,11 @@ def self.included(base) base.include PreludeHandling end - def stream_url + def completion_stream_url "model/#{@model_id}/invoke-with-response-stream" end - def stream_response(connection, payload, &block) + def stream_response(connection, stream_url, payload, &block) signature = sign_request("#{connection.connection.url_prefix}#{stream_url}", config: connection.config, payload:) accumulator = StreamAccumulator.new diff --git a/lib/ruby_llm/providers/gemini/chat.rb b/lib/ruby_llm/providers/gemini/chat.rb index d6ba1696f..8a455efb7 100644 --- a/lib/ruby_llm/providers/gemini/chat.rb +++ b/lib/ruby_llm/providers/gemini/chat.rb @@ -11,8 +11,8 @@ def completion_url "models/#{@model}:generateContent" end - def render_payload(messages, tools:, temperature:, model:, stream: false) # rubocop:disable Lint/UnusedMethodArgument - @model = model # Store model for completion_url/stream_url + def render_completion_payload(messages, tools:, temperature:, model:, stream: false) # rubocop:disable Lint/UnusedMethodArgument + @model = model # Store model for completion_url/completion_stream_url payload = { contents: format_messages(messages), generationConfig: { diff --git a/lib/ruby_llm/providers/gemini/streaming.rb b/lib/ruby_llm/providers/gemini/streaming.rb index edf9efd5b..2017e0b16 100644 --- a/lib/ruby_llm/providers/gemini/streaming.rb +++ b/lib/ruby_llm/providers/gemini/streaming.rb @@ -5,7 +5,7 @@ module Providers module Gemini # Streaming methods for the Gemini API implementation module Streaming - def stream_url + def completion_stream_url "models/#{@model}:streamGenerateContent?alt=sse" end diff --git a/lib/ruby_llm/providers/openai.rb b/lib/ruby_llm/providers/openai.rb index 7ad39d9c1..c1731f812 100644 --- a/lib/ruby_llm/providers/openai.rb +++ b/lib/ruby_llm/providers/openai.rb @@ -8,6 +8,7 @@ module Providers module OpenAI extend Provider extend OpenAI::Chat + extend OpenAI::Response extend OpenAI::Embeddings extend OpenAI::Models extend OpenAI::Streaming @@ -18,6 +19,7 @@ module OpenAI def self.extended(base) base.extend(Provider) base.extend(OpenAI::Chat) + base.extend(OpenAI::Response) base.extend(OpenAI::Embeddings) base.extend(OpenAI::Models) base.extend(OpenAI::Streaming) diff --git a/lib/ruby_llm/providers/openai/chat.rb b/lib/ruby_llm/providers/openai/chat.rb index 697442b2f..12c143e0f 100644 --- a/lib/ruby_llm/providers/openai/chat.rb +++ b/lib/ruby_llm/providers/openai/chat.rb @@ -9,9 +9,13 @@ def completion_url 'chat/completions' end + def responses_url + 'responses' + end + module_function - def render_payload(messages, tools:, temperature:, model:, stream: false) + def render_completion_payload(messages, tools:, temperature:, model:, stream: false) payload = { model: model, messages: format_messages(messages), @@ -22,7 +26,26 @@ def render_payload(messages, tools:, temperature:, model:, stream: false) payload[:temperature] = temperature unless temperature.nil? if tools.any? - payload[:tools] = tools.map { |_, tool| tool_for(tool) } + payload[:tools] = tools.map { |_, tool| chat_tool_for(tool) } + payload[:tool_choice] = 'auto' + end + + payload[:stream_options] = { include_usage: true } if stream + payload + end + + def render_response_payload(messages, tools:, temperature:, model:, stream: false) + payload = { + model: model, + input: format_input(messages), + stream: stream + } + + # Only include temperature if it's not nil (some models don't accept it) + payload[:temperature] = temperature unless temperature.nil? + + if tools.any? + payload[:tools] = tools.map { |_, tool| response_tool_for(tool) } payload[:tool_choice] = 'auto' end @@ -49,6 +72,32 @@ def parse_completion_response(response) ) end + def parse_respond_response(response) + data = response.body + return if data.empty? + + raise Error.new(response, data.dig('error', 'message')) if data.dig('error', 'message') + + outputs = data['output'] + return unless outputs.any? + + Message.new( + role: :assistant, + content: all_output_text(outputs), + tool_calls: parse_response_tool_calls(outputs), + input_tokens: data['usage']['input_tokens'], + output_tokens: data['usage']['output_tokens'], + model_id: data['model'] + ) + end + + def all_output_text(outputs) + outputs.select { |o| o['type'] == 'message' }.flat_map do |o| + output_texts = o['content'].select { |c| c['type'] == 'output_text' } + output_texts.map { |c| c['text'] }.join("\n") + end + end + def format_messages(messages) messages.map do |msg| { @@ -60,6 +109,39 @@ def format_messages(messages) end end + def format_input(messages) # rubocop:disable Metrics/PerceivedComplexity + all_tool_calls = messages.flat_map do |m| + m.tool_calls&.values || [] + end + messages.flat_map do |msg| + if msg.tool_call? + msg.tool_calls.map do |_, tc| + { + type: 'function_call', + call_id: tc.id, + name: tc.name, + arguments: JSON.generate(tc.arguments), + status: 'completed' + } + end + elsif msg.role == :tool + { + type: 'function_call_output', + call_id: all_tool_calls.detect { |tc| tc.id == msg.tool_call_id }&.id, + output: msg.content, + status: 'completed' + } + else + { + type: 'message', + role: format_role(msg.role), + content: Media.format_content(msg.content), + status: 'completed' + }.compact + end + end + end + def format_role(role) case role when :system diff --git a/lib/ruby_llm/providers/openai/response.rb b/lib/ruby_llm/providers/openai/response.rb new file mode 100644 index 000000000..156a1f2cf --- /dev/null +++ b/lib/ruby_llm/providers/openai/response.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +module RubyLLM + module Providers + module OpenAI + # Response methods of the OpenAI API integration + module Response + def responses_url + 'responses' + end + + module_function + + def parse_respond_response(response) + data = response.body + return if data.empty? + + raise Error.new(response, data.dig('error', 'message')) if data.dig('error', 'message') + + outputs = data['output'] + return unless outputs.any? + + Message.new( + role: :assistant, + content: all_output_text(outputs), + tool_calls: parse_response_tool_calls(outputs), + input_tokens: data['usage']['input_tokens'], + output_tokens: data['usage']['output_tokens'], + model_id: data['model'] + ) + end + + def all_output_text(outputs) + outputs.select { |o| o['type'] == 'message' }.flat_map do |o| + o['content'].filter_map do |c| + c['type'] == 'output_text' && c['text'] + end + end.join("\n") + end + end + end + end +end diff --git a/lib/ruby_llm/providers/openai/streaming.rb b/lib/ruby_llm/providers/openai/streaming.rb index ba3134475..ae91f3748 100644 --- a/lib/ruby_llm/providers/openai/streaming.rb +++ b/lib/ruby_llm/providers/openai/streaming.rb @@ -7,10 +7,14 @@ module OpenAI module Streaming module_function - def stream_url + def completion_stream_url completion_url end + def responses_stream_url + responses_url + end + def build_chunk(data) Chunk.new( role: :assistant, diff --git a/lib/ruby_llm/providers/openai/tools.rb b/lib/ruby_llm/providers/openai/tools.rb index 51743afb5..53de97414 100644 --- a/lib/ruby_llm/providers/openai/tools.rb +++ b/lib/ruby_llm/providers/openai/tools.rb @@ -7,21 +7,26 @@ module OpenAI module Tools module_function - def tool_for(tool) + def chat_tool_for(tool) { type: 'function', function: { name: tool.name, description: tool.description, - parameters: { - type: 'object', - properties: tool.parameters.transform_values { |param| param_schema(param) }, - required: tool.parameters.select { |_, p| p.required }.keys - } + parameters: tool_parameters_for(tool) } } end + def response_tool_for(tool) + { + type: 'function', + name: tool.name, + description: tool.description, + parameters: tool_parameters_for(tool) + } + end + def param_schema(param) { type: param.type, @@ -29,6 +34,14 @@ def param_schema(param) }.compact end + def tool_parameters_for(tool) + { + type: 'object', + properties: tool.parameters.transform_values { |param| param_schema(param) }, + required: tool.parameters.select { |_, p| p.required }.keys + } + end + def format_tool_calls(tool_calls) return nil unless tool_calls&.any? @@ -67,6 +80,20 @@ def parse_tool_calls(tool_calls, parse_arguments: true) ] end end + + def parse_response_tool_calls(outputs) + # TODO: implement the other & built-in tools + # 'web_search_call', 'file_search_call', 'image_generation_call', + # 'code_interpreter_call', 'local_shell_call', 'mcp_call', + # 'mcp_list_tools', 'mcp_approval_request' + outputs.select { |o| o['type'] == 'function_call' }.to_h do |o| + [o['id'], ToolCall.new( + id: o['call_id'], + name: o['name'], + arguments: JSON.parse(o['arguments']) + )] + end + end end end end diff --git a/lib/ruby_llm/response.rb b/lib/ruby_llm/response.rb new file mode 100644 index 000000000..6cfd7cdbd --- /dev/null +++ b/lib/ruby_llm/response.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module RubyLLM + # Represents a response from an AI model. Handles tool integration. + # + # Example: + # response = RubyLLM.response + # response.ask "What's the best way to learn Ruby?" + class Response < Conversation + def get_response(&) + @provider.respond( + messages, + tools: @tools, + temperature: @temperature, + model: @model.id, + connection: @connection, + params: @params, + & + ) + end + end +end diff --git a/lib/ruby_llm/streaming.rb b/lib/ruby_llm/streaming.rb index b7017896e..4b0a5c3c5 100644 --- a/lib/ruby_llm/streaming.rb +++ b/lib/ruby_llm/streaming.rb @@ -8,7 +8,7 @@ module RubyLLM module Streaming module_function - def stream_response(connection, payload, &block) + def stream_response(connection, stream_url, payload, &block) accumulator = StreamAccumulator.new connection.post stream_url, payload do |req| diff --git a/spec/fixtures/vcr_cassettes/activerecord_actsas_error_handling_destroys_empty_assistant_messages_on_api_failure.yml b/spec/fixtures/vcr_cassettes/activerecord_actsas_error_handling_destroys_empty_assistant_messages_on_api_failure.yml new file mode 100644 index 000000000..2ea9bcdf4 --- /dev/null +++ b/spec/fixtures/vcr_cassettes/activerecord_actsas_error_handling_destroys_empty_assistant_messages_on_api_failure.yml @@ -0,0 +1,115 @@ +--- +http_interactions: +- request: + method: post + uri: https://api.openai.com/v1/chat/completions + body: + encoding: UTF-8 + string: '{"model":"gpt-4.1-nano","messages":[{"role":"user","content":"This + will fail"}],"stream":false,"temperature":0.7}' + headers: + User-Agent: + - Faraday v2.12.2 + Authorization: + - Bearer + Content-Type: + - application/json + Accept-Encoding: + - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 + Accept: + - "*/*" + response: + status: + code: 200 + message: OK + headers: + Date: + - Wed, 23 Jul 2025 06:53:29 GMT + Content-Type: + - application/json + Transfer-Encoding: + - chunked + Connection: + - keep-alive + Access-Control-Expose-Headers: + - X-Request-ID + Openai-Organization: + - "" + Openai-Processing-Ms: + - '870' + Openai-Project: + - proj_c1LXe0DmfaJxa0MxGDmocdjJ + Openai-Version: + - '2020-10-01' + X-Envoy-Upstream-Service-Time: + - '901' + X-Ratelimit-Limit-Requests: + - '5000' + X-Ratelimit-Limit-Tokens: + - '2000000' + X-Ratelimit-Remaining-Requests: + - '4999' + X-Ratelimit-Remaining-Tokens: + - '1999994' + X-Ratelimit-Reset-Requests: + - 12ms + X-Ratelimit-Reset-Tokens: + - 0s + X-Request-Id: + - "" + Strict-Transport-Security: + - max-age=31536000; includeSubDomains; preload + Cf-Cache-Status: + - DYNAMIC + Set-Cookie: + - "" + - "" + X-Content-Type-Options: + - nosniff + Server: + - cloudflare + Cf-Ray: + - "" + Alt-Svc: + - h3=":443"; ma=86400 + body: + encoding: ASCII-8BIT + string: | + { + "id": "chatcmpl-BwNdw4V4t4ZnrYOfeg9HuzJQEc7Vk", + "object": "chat.completion", + "created": 1753253608, + "model": "gpt-4.1-nano-2025-04-14", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": "It sounds like you're concerned that something might not work as expected. Could you please provide more details or clarify what you're referring to? I'm here to help!", + "refusal": null, + "annotations": [] + }, + "logprobs": null, + "finish_reason": "stop" + } + ], + "usage": { + "prompt_tokens": 10, + "completion_tokens": 31, + "total_tokens": 41, + "prompt_tokens_details": { + "cached_tokens": 0, + "audio_tokens": 0 + }, + "completion_tokens_details": { + "reasoning_tokens": 0, + "audio_tokens": 0, + "accepted_prediction_tokens": 0, + "rejected_prediction_tokens": 0 + } + }, + "service_tier": "default", + "system_fingerprint": null + } + recorded_at: Wed, 23 Jul 2025 06:53:29 GMT +recorded_with: VCR 6.3.1 diff --git a/spec/fixtures/vcr_cassettes/response_basic_response_functionality_openai_o4-mini-deep-research_can_respond.yml b/spec/fixtures/vcr_cassettes/response_basic_response_functionality_openai_o4-mini-deep-research_can_respond.yml new file mode 100644 index 000000000..993ac6bc8 --- /dev/null +++ b/spec/fixtures/vcr_cassettes/response_basic_response_functionality_openai_o4-mini-deep-research_can_respond.yml @@ -0,0 +1,446 @@ +--- +http_interactions: +- request: + method: post + uri: https://api.openai.com/v1/responses + body: + encoding: UTF-8 + string: '{"tools":[{"type":"web_search_preview"}],"model":"o4-mini-deep-research","input":[{"role":"user","content":"At + what temperature does water boil (in Celsius)?"}],"stream":false,"temperature":1.0}' + headers: + User-Agent: + - Faraday v2.12.2 + Authorization: + - Bearer + Content-Type: + - application/json + Accept-Encoding: + - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 + Accept: + - "*/*" + response: + status: + code: 200 + message: OK + headers: + Date: + - Tue, 22 Jul 2025 20:22:01 GMT + Content-Type: + - application/json + Transfer-Encoding: + - chunked + Connection: + - keep-alive + X-Ratelimit-Limit-Requests: + - '5000' + X-Ratelimit-Limit-Tokens: + - '2000000' + X-Ratelimit-Remaining-Requests: + - '4999' + X-Ratelimit-Remaining-Tokens: + - '1988636' + X-Ratelimit-Reset-Requests: + - 12ms + X-Ratelimit-Reset-Tokens: + - 340ms + Openai-Version: + - '2020-10-01' + Openai-Organization: + - "" + Openai-Project: + - proj_c1LXe0DmfaJxa0MxGDmocdjJ + X-Request-Id: + - "" + Openai-Processing-Ms: + - '63465' + Strict-Transport-Security: + - max-age=31536000; includeSubDomains; preload + Cf-Cache-Status: + - DYNAMIC + Set-Cookie: + - "" + - "" + X-Content-Type-Options: + - nosniff + Server: + - cloudflare + Cf-Ray: + - "" + Alt-Svc: + - h3=":443"; ma=86400 + body: + encoding: ASCII-8BIT + string: |- + { + "id": "resp_687ff2aa365c8199aa5507c8b3baef3e04e204e1133d12d7", + "object": "response", + "created_at": 1753215658, + "status": "completed", + "background": false, + "error": null, + "incomplete_details": null, + "instructions": null, + "max_output_tokens": null, + "max_tool_calls": 225, + "model": "o4-mini-deep-research-2025-06-26", + "output": [ + { + "id": "rs_687ff2aacfb081999aea60da9ffc46e004e204e1133d12d7", + "type": "reasoning", + "summary": [] + }, + { + "id": "ws_687ff2ac4574819999fdbe52a15dac9f04e204e1133d12d7", + "type": "web_search_call", + "status": "completed", + "action": { + "type": "search", + "query": "\"boils at 100\u00b0C\"" + } + }, + { + "id": "rs_687ff2b3924081998979e23aebc73d6a04e204e1133d12d7", + "type": "reasoning", + "summary": [] + }, + { + "id": "ws_687ff2b41ef8819985e7490c2af8bd4204e204e1133d12d7", + "type": "web_search_call", + "status": "completed", + "action": { + "type": "search", + "query": "\"water boils at 100\u00b0C\" source:wikipedia" + } + }, + { + "id": "rs_687ff2b5797881999dd4f5a124a9913e04e204e1133d12d7", + "type": "reasoning", + "summary": [] + }, + { + "id": "ws_687ff2b6927c8199a0f500fdfb257ecf04e204e1133d12d7", + "type": "web_search_call", + "status": "completed", + "action": { + "type": "search", + "query": "water boils at 100\bC Britannica" + } + }, + { + "id": "rs_687ff2b7e8888199a4e8c7ff4c88b25d04e204e1133d12d7", + "type": "reasoning", + "summary": [] + }, + { + "id": "ws_687ff2b84bb8819996e14f7f76059eec04e204e1133d12d7", + "type": "web_search_call", + "status": "completed", + "action": { + "type": "open_page", + "url": "https://www.britannica.com/question/When-does-water-boil" + } + }, + { + "id": "rs_687ff2b8ce788199b042cb1531409d7204e204e1133d12d7", + "type": "reasoning", + "summary": [] + }, + { + "id": "ws_687ff2b95ba08199a052fabc4625c7d504e204e1133d12d7", + "type": "web_search_call", + "status": "completed", + "action": { + "type": "search", + "query": "\"water boils at 100\u00b0C\" sea level" + } + }, + { + "id": "rs_687ff2baf2f48199a8fe9fbd0ab716d804e204e1133d12d7", + "type": "reasoning", + "summary": [] + }, + { + "id": "ws_687ff2bdc8e88199b955c90db08fab2b04e204e1133d12d7", + "type": "web_search_call", + "status": "completed", + "action": { + "type": "search", + "query": "\"100\u00179C at sea level\" water" + } + }, + { + "id": "rs_687ff2bf69508199b63c6a7790076b1c04e204e1133d12d7", + "type": "reasoning", + "summary": [] + }, + { + "id": "ws_687ff2bfbdd88199a0f8c05c8cc9da5404e204e1133d12d7", + "type": "web_search_call", + "status": "completed", + "action": { + "type": "search", + "query": "\"boils at 100\u0001C\" water standard" + } + }, + { + "id": "rs_687ff2c01c788199adab462cb196a7fc04e204e1133d12d7", + "type": "reasoning", + "summary": [] + }, + { + "id": "ws_687ff2c0b4dc81999d4e661543f1699c04e204e1133d12d7", + "type": "web_search_call", + "status": "completed", + "action": { + "type": "search", + "query": "\"100 degrees Celsius\" boiling point water" + } + }, + { + "id": "rs_687ff2c1ece081998256aafeff67af1104e204e1133d12d7", + "type": "reasoning", + "summary": [] + }, + { + "id": "ws_687ff2c435508199a7e354cc09bd98fe04e204e1133d12d7", + "type": "web_search_call", + "status": "completed", + "action": { + "type": "search", + "query": "\"water boils at 100 degrees Celsius\" pressure" + } + }, + { + "id": "rs_687ff2c58fe08199b3452e3e79e8c87004e204e1133d12d7", + "type": "reasoning", + "summary": [] + }, + { + "id": "ws_687ff2c80cb481998857d2c2b6202e8a04e204e1133d12d7", + "type": "web_search_call", + "status": "completed", + "action": { + "type": "open_page", + "url": "https://www.britannica.com/science/boiling-point" + } + }, + { + "id": "rs_687ff2c8afc48199b55d6064a4b8374004e204e1133d12d7", + "type": "reasoning", + "summary": [] + }, + { + "id": "ws_687ff2c9756481998354313cae64c82904e204e1133d12d7", + "type": "web_search_call", + "status": "completed", + "action": { + "type": "search", + "query": "\"Water boils at 100\" site:britannica.com" + } + }, + { + "id": "rs_687ff2cae024819980f7e07640d215c204e204e1133d12d7", + "type": "reasoning", + "summary": [] + }, + { + "id": "ws_687ff2cb26f88199b22740b92cce524d04e204e1133d12d7", + "type": "web_search_call", + "status": "completed", + "action": { + "type": "open_page", + "url": "https://kids.britannica.com/students/article/water/277663" + } + }, + { + "id": "rs_687ff2cbb80c81999b2860d3596831b004e204e1133d12d7", + "type": "reasoning", + "summary": [] + }, + { + "id": "ws_687ff2cbd7848199ad7125d8e938d86d04e204e1133d12d7", + "type": "web_search_call", + "status": "completed", + "action": { + "type": "find_in_page", + "pattern": "boils", + "url": "https://kids.britannica.com/students/article/water/277663" + } + }, + { + "id": "rs_687ff2cc2378819991eb3899676aaa9404e204e1133d12d7", + "type": "reasoning", + "summary": [] + }, + { + "id": "ws_687ff2ccd190819993b7cb79428d1c2704e204e1133d12d7", + "type": "web_search_call", + "status": "completed", + "action": { + "type": "open_page", + "url": "https://kids.britannica.com/students/article/water/277663" + } + }, + { + "id": "rs_687ff2cd76e8819987304d23113c9a7704e204e1133d12d7", + "type": "reasoning", + "summary": [] + }, + { + "id": "ws_687ff2cf252c8199bbb7a7bd3daf99e804e204e1133d12d7", + "type": "web_search_call", + "status": "completed", + "action": { + "type": "open_page", + "url": null + } + }, + { + "id": "rs_687ff2cf8f708199ac1b889e0f9d26eb04e204e1133d12d7", + "type": "reasoning", + "summary": [] + }, + { + "id": "ws_687ff2d1b10c81999f34c64f189fdf9604e204e1133d12d7", + "type": "web_search_call", + "status": "completed", + "action": { + "type": "open_page", + "url": "https://kids.britannica.com/students/article/water/277663" + } + }, + { + "id": "rs_687ff2d2063481999db946d3c0c6894204e204e1133d12d7", + "type": "reasoning", + "summary": [] + }, + { + "id": "ws_687ff2d486b08199bc1b3ddd1790b86104e204e1133d12d7", + "type": "web_search_call", + "status": "completed", + "action": { + "type": "find_in_page", + "pattern": "100 \u00125C", + "url": "https://kids.britannica.com/students/article/water/277663" + } + }, + { + "id": "rs_687ff2d4df888199b02e20f573e2f55104e204e1133d12d7", + "type": "reasoning", + "summary": [] + }, + { + "id": "ws_687ff2d629d4819989bd925e8f7bb14804e204e1133d12d7", + "type": "web_search_call", + "status": "completed", + "action": { + "type": "search", + "query": "\"100\u0001C (212\u0001F) at standard pressure\"" + } + }, + { + "id": "rs_687ff2d7c4348199a299f29b1c31209904e204e1133d12d7", + "type": "reasoning", + "summary": [] + }, + { + "id": "ws_687ff2d84d40819992eb1bf5fc0b95b004e204e1133d12d7", + "type": "web_search_call", + "status": "completed", + "action": { + "type": "search", + "query": "\"100\u0001C\" boiling water NOAA" + } + }, + { + "id": "rs_687ff2da28588199aa2da963f37f480f04e204e1133d12d7", + "type": "reasoning", + "summary": [] + }, + { + "id": "ws_687ff2da8a5c819991fc0e042a43aa6104e204e1133d12d7", + "type": "web_search_call", + "status": "completed", + "action": { + "type": "search", + "query": "\"vapour pressure of water = atmospheric pressure\" 100\u0001C" + } + }, + { + "id": "rs_687ff2dc087081999dc8b019598062b204e204e1133d12d7", + "type": "reasoning", + "summary": [] + }, + { + "id": "msg_687ff2e8800c819998c4f8b59f78478a04e204e1133d12d7", + "type": "message", + "status": "completed", + "content": [ + { + "type": "output_text", + "annotations": [ + { + "type": "url_citation", + "end_index": 305, + "start_index": 126, + "title": "water - Students | Britannica Kids | Homework Help", + "url": "https://kids.britannica.com/students/article/water/277663#:~:text=Atmospheric%20pressure%20also%20affects%20the,and%20freezes%20at%20higher%20temperatures" + }, + { + "type": "url_citation", + "end_index": 585, + "start_index": 406, + "title": "water - Students | Britannica Kids | Homework Help", + "url": "https://kids.britannica.com/students/article/water/277663#:~:text=Atmospheric%20pressure%20also%20affects%20the,and%20freezes%20at%20higher%20temperatures" + } + ], + "logprobs": [], + "text": "# Boiling Point of Water\n\n- Under standard atmospheric pressure (1 atm at sea level), pure water boils at **100 \u00b0C** (212 \u00b0F) ([kids.britannica.com](https://kids.britannica.com/students/article/water/277663#:~:text=Atmospheric%20pressure%20also%20affects%20the,and%20freezes%20at%20higher%20temperatures)). \n- At higher altitudes (lower pressure), water boils at lower temperatures. \n\n**Answer:** 100 \u00b0C ([kids.britannica.com](https://kids.britannica.com/students/article/water/277663#:~:text=Atmospheric%20pressure%20also%20affects%20the,and%20freezes%20at%20higher%20temperatures))" + } + ], + "role": "assistant" + } + ], + "parallel_tool_calls": true, + "previous_response_id": null, + "prompt_cache_key": null, + "reasoning": { + "effort": "medium", + "summary": null + }, + "safety_identifier": null, + "service_tier": "default", + "store": true, + "temperature": 1.0, + "text": { + "format": { + "type": "text" + } + }, + "tool_choice": "auto", + "tools": [ + { + "type": "web_search_preview", + "search_context_size": "medium", + "user_location": null + } + ], + "top_logprobs": 0, + "top_p": 1.0, + "truncation": "disabled", + "usage": { + "input_tokens": 125508, + "input_tokens_details": { + "cached_tokens": 85332 + }, + "output_tokens": 4753, + "output_tokens_details": { + "reasoning_tokens": 4672 + }, + "total_tokens": 130261 + }, + "user": null, + "metadata": {} + } + recorded_at: Tue, 22 Jul 2025 20:22:01 GMT +recorded_with: VCR 6.3.1 diff --git a/spec/fixtures/vcr_cassettes/response_tool_calling_can_use_tools.yml b/spec/fixtures/vcr_cassettes/response_tool_calling_can_use_tools.yml new file mode 100644 index 000000000..2c1e5f496 --- /dev/null +++ b/spec/fixtures/vcr_cassettes/response_tool_calling_can_use_tools.yml @@ -0,0 +1,304 @@ +--- +http_interactions: +- request: + method: post + uri: https://api.openai.com/v1/responses + body: + encoding: UTF-8 + string: '{"model":"o4-mini","input":[{"type":"message","role":"user","content":"What + is the current computer''s weight in pounds?","status":"completed"}],"stream":false,"temperature":1.0,"tools":[{"type":"function","name":"current_computer_weight","description":"Get + the current computer weight in kg","parameters":{"type":"object","properties":{},"required":[]}}],"tool_choice":"auto"}' + headers: + User-Agent: + - Faraday v2.12.2 + Authorization: + - Bearer + Content-Type: + - application/json + Accept-Encoding: + - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 + Accept: + - "*/*" + response: + status: + code: 200 + message: OK + headers: + Date: + - Wed, 23 Jul 2025 06:31:38 GMT + Content-Type: + - application/json + Transfer-Encoding: + - chunked + Connection: + - keep-alive + X-Ratelimit-Limit-Requests: + - '5000' + X-Ratelimit-Limit-Tokens: + - '2000000' + X-Ratelimit-Remaining-Requests: + - '4999' + X-Ratelimit-Remaining-Tokens: + - '1999689' + X-Ratelimit-Reset-Requests: + - 12ms + X-Ratelimit-Reset-Tokens: + - 9ms + Openai-Version: + - '2020-10-01' + Openai-Organization: + - "" + Openai-Project: + - proj_c1LXe0DmfaJxa0MxGDmocdjJ + X-Request-Id: + - "" + Openai-Processing-Ms: + - '4448' + Strict-Transport-Security: + - max-age=31536000; includeSubDomains; preload + Cf-Cache-Status: + - DYNAMIC + Set-Cookie: + - "" + - "" + X-Content-Type-Options: + - nosniff + Server: + - cloudflare + Cf-Ray: + - "" + Alt-Svc: + - h3=":443"; ma=86400 + body: + encoding: ASCII-8BIT + string: |- + { + "id": "resp_688081c5c8888198a6728996c7bf787a0f2a380e1c1b09f0", + "object": "response", + "created_at": 1753252293, + "status": "completed", + "background": false, + "error": null, + "incomplete_details": null, + "instructions": null, + "max_output_tokens": null, + "max_tool_calls": null, + "model": "o4-mini-2025-04-16", + "output": [ + { + "id": "rs_688081c6df5c819884872de943b0dd090f2a380e1c1b09f0", + "type": "reasoning", + "summary": [] + }, + { + "id": "fc_688081c9c190819889aacf6f863eb9a00f2a380e1c1b09f0", + "type": "function_call", + "status": "completed", + "arguments": "{}", + "call_id": "call_laMqYMWMrbZP4k0iPh7MJYSj", + "name": "current_computer_weight" + } + ], + "parallel_tool_calls": true, + "previous_response_id": null, + "prompt_cache_key": null, + "reasoning": { + "effort": "medium", + "summary": null + }, + "safety_identifier": null, + "service_tier": "default", + "store": true, + "temperature": 1.0, + "text": { + "format": { + "type": "text" + } + }, + "tool_choice": "auto", + "tools": [ + { + "type": "function", + "description": "Get the current computer weight in kg", + "name": "current_computer_weight", + "parameters": { + "type": "object", + "properties": {}, + "required": [] + }, + "strict": true + } + ], + "top_logprobs": 0, + "top_p": 1.0, + "truncation": "disabled", + "usage": { + "input_tokens": 47, + "input_tokens_details": { + "cached_tokens": 0 + }, + "output_tokens": 338, + "output_tokens_details": { + "reasoning_tokens": 320 + }, + "total_tokens": 385 + }, + "user": null, + "metadata": {} + } + recorded_at: Wed, 23 Jul 2025 06:31:38 GMT +- request: + method: post + uri: https://api.openai.com/v1/responses + body: + encoding: UTF-8 + string: '{"model":"o4-mini","input":[{"type":"message","role":"user","content":"What + is the current computer''s weight in pounds?","status":"completed"},{"type":"function_call","call_id":"call_laMqYMWMrbZP4k0iPh7MJYSj","name":"current_computer_weight","arguments":"{}","status":"completed"},{"type":"function_call_output","call_id":"call_laMqYMWMrbZP4k0iPh7MJYSj","output":"100 + kg","status":"completed"}],"stream":false,"temperature":1.0,"tools":[{"type":"function","name":"current_computer_weight","description":"Get + the current computer weight in kg","parameters":{"type":"object","properties":{},"required":[]}}],"tool_choice":"auto"}' + headers: + User-Agent: + - Faraday v2.12.2 + Authorization: + - Bearer + Content-Type: + - application/json + Accept-Encoding: + - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 + Accept: + - "*/*" + response: + status: + code: 200 + message: OK + headers: + Date: + - Wed, 23 Jul 2025 06:31:41 GMT + Content-Type: + - application/json + Transfer-Encoding: + - chunked + Connection: + - keep-alive + X-Ratelimit-Limit-Requests: + - '5000' + X-Ratelimit-Limit-Tokens: + - '2000000' + X-Ratelimit-Remaining-Requests: + - '4999' + X-Ratelimit-Remaining-Tokens: + - '1999662' + X-Ratelimit-Reset-Requests: + - 12ms + X-Ratelimit-Reset-Tokens: + - 10ms + Openai-Version: + - '2020-10-01' + Openai-Organization: + - "" + Openai-Project: + - proj_c1LXe0DmfaJxa0MxGDmocdjJ + X-Request-Id: + - "" + Openai-Processing-Ms: + - '3125' + Strict-Transport-Security: + - max-age=31536000; includeSubDomains; preload + Cf-Cache-Status: + - DYNAMIC + Set-Cookie: + - "" + - "" + X-Content-Type-Options: + - nosniff + Server: + - cloudflare + Cf-Ray: + - "" + Alt-Svc: + - h3=":443"; ma=86400 + body: + encoding: ASCII-8BIT + string: |- + { + "id": "resp_688081ca7fc8819bbf60f869941f50b9088e3676a06e1449", + "object": "response", + "created_at": 1753252298, + "status": "completed", + "background": false, + "error": null, + "incomplete_details": null, + "instructions": null, + "max_output_tokens": null, + "max_tool_calls": null, + "model": "o4-mini-2025-04-16", + "output": [ + { + "id": "rs_688081cb08cc819ba87d267957e3856f088e3676a06e1449", + "type": "reasoning", + "summary": [] + }, + { + "id": "msg_688081ccda9c819bb558696f4b3d1a12088e3676a06e1449", + "type": "message", + "status": "completed", + "content": [ + { + "type": "output_text", + "annotations": [], + "logprobs": [], + "text": "The computer weighs 100 kg, which is about 220.46 lb (using 1 kg \u2248 2.20462 lb)." + } + ], + "role": "assistant" + } + ], + "parallel_tool_calls": true, + "previous_response_id": null, + "prompt_cache_key": null, + "reasoning": { + "effort": "medium", + "summary": null + }, + "safety_identifier": null, + "service_tier": "default", + "store": true, + "temperature": 1.0, + "text": { + "format": { + "type": "text" + } + }, + "tool_choice": "auto", + "tools": [ + { + "type": "function", + "description": "Get the current computer weight in kg", + "name": "current_computer_weight", + "parameters": { + "type": "object", + "properties": {}, + "required": [] + }, + "strict": true + } + ], + "top_logprobs": 0, + "top_p": 1.0, + "truncation": "disabled", + "usage": { + "input_tokens": 75, + "input_tokens_details": { + "cached_tokens": 0 + }, + "output_tokens": 167, + "output_tokens_details": { + "reasoning_tokens": 128 + }, + "total_tokens": 242 + }, + "user": null, + "metadata": {} + } + recorded_at: Wed, 23 Jul 2025 06:31:41 GMT +recorded_with: VCR 6.3.1 diff --git a/spec/ruby_llm/active_record/acts_as_spec.rb b/spec/ruby_llm/active_record/acts_as_spec.rb index 564416fc4..df24fcbeb 100644 --- a/spec/ruby_llm/active_record/acts_as_spec.rb +++ b/spec/ruby_llm/active_record/acts_as_spec.rb @@ -99,7 +99,7 @@ def execute(expression:) chat = Chat.create!(model_id: model) # Stub the API to fail - allow_any_instance_of(RubyLLM::Chat).to receive(:complete).and_raise(RubyLLM::Error) # rubocop:disable RSpec/AnyInstance + allow_any_instance_of(RubyLLM::Chat).to receive(:process).and_raise(RubyLLM::Error) # rubocop:disable RSpec/AnyInstance expect { chat.ask('This will fail') }.to raise_error(RubyLLM::Error) diff --git a/spec/ruby_llm/chat_request_options_spec.rb b/spec/ruby_llm/chat_request_options_spec.rb index 07a3f87b2..717ded223 100644 --- a/spec/ruby_llm/chat_request_options_spec.rb +++ b/spec/ruby_llm/chat_request_options_spec.rb @@ -73,7 +73,7 @@ content: '{' ) - response = chat.complete + response = chat.get_response json_response = JSON.parse('{' + response.content) # rubocop:disable Style/StringConcatenation expect(json_response).to eq({ 'result' => 8 }) @@ -100,7 +100,7 @@ content: '{' ) - response = chat.complete + response = chat.get_response json_response = JSON.parse('{' + response.content) # rubocop:disable Style/StringConcatenation expect(json_response).to eq({ 'result' => 8 }) diff --git a/spec/ruby_llm/providers/anthropic/tools_spec.rb b/spec/ruby_llm/providers/anthropic/tools_spec.rb index 56f190621..4f37cb04e 100644 --- a/spec/ruby_llm/providers/anthropic/tools_spec.rb +++ b/spec/ruby_llm/providers/anthropic/tools_spec.rb @@ -10,7 +10,7 @@ instance_double(Message, content: 'Some content', tool_calls: { - 'tool_123' => instance_double(ToolCall, + 'tool_123' => instance_double(RubyLLM::ToolCall, id: 'tool_123', name: 'test_tool', arguments: { 'arg1' => 'value1' }) @@ -39,7 +39,7 @@ instance_double(Message, content: nil, tool_calls: { - 'tool_123' => instance_double(ToolCall, + 'tool_123' => instance_double(RubyLLM::ToolCall, id: 'tool_123', name: 'test_tool', arguments: { 'arg1' => 'value1' }) @@ -68,7 +68,7 @@ instance_double(Message, content: '', tool_calls: { - 'tool_123' => instance_double(ToolCall, + 'tool_123' => instance_double(RubyLLM::ToolCall, id: 'tool_123', name: 'test_tool', arguments: { 'arg1' => 'value1' }) diff --git a/spec/ruby_llm/response_spec.rb b/spec/ruby_llm/response_spec.rb new file mode 100644 index 000000000..2dd6dceb6 --- /dev/null +++ b/spec/ruby_llm/response_spec.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe RubyLLM::Response do + include_context 'with configured RubyLLM' + + class CurrentComputerWeight < RubyLLM::Tool # rubocop:disable Lint/ConstantDefinitionInBlock,RSpec/LeakyConstantDeclaration + def description + 'Get the current computer weight in kg' + end + + def name + 'current_computer_weight' + end + + def execute + '100 kg' + end + end + + describe 'basic response functionality' do + RESPONSE_MODELS.each do |model_info| + model = model_info[:model] + provider = model_info[:provider] + params = model_info[:params] + it "#{provider}/#{model} can respond" do # rubocop:disable RSpec/ExampleLength,RSpec/MultipleExpectations + chat = RubyLLM.response(model: model, provider: provider).with_params(**(params || {})) + response = chat.ask('At what temperature does water boil (in Celsius)?') + + expect(response.content).to include('100') + expect(response.role).to eq(:assistant) + expect(response.input_tokens).to be_positive + expect(response.output_tokens).to be_positive + end + end + end + + describe 'tool calling' do + it 'can use tools' do # rubocop:disable RSpec/MultipleExpectations + chat = RubyLLM.response(model: 'o4-mini', provider: :openai).with_tool(CurrentComputerWeight) + response = chat.ask('What is the current computer\'s weight in pounds?') + + expect(response.content).to include('220') + expect(response.role).to eq(:assistant) + end + end +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index c8286fd69..48f509126 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -143,6 +143,10 @@ end end +RESPONSE_MODELS = [ + { provider: :openai, model: 'o4-mini-deep-research', params: { tools: [{ type: 'web_search_preview' }] } } +].freeze + CHAT_MODELS = [ { provider: :anthropic, model: 'claude-3-5-haiku-20241022' }, { provider: :bedrock, model: 'anthropic.claude-3-5-haiku-20241022-v1:0' }, From 0a1c980e4ac59288a7f2ddc1f8129ab783405b57 Mon Sep 17 00:00:00 2001 From: Sylvain Utard Date: Wed, 23 Jul 2025 09:38:36 +0200 Subject: [PATCH 2/2] useless --- ...mpty_assistant_messages_on_api_failure.yml | 115 ------------------ 1 file changed, 115 deletions(-) delete mode 100644 spec/fixtures/vcr_cassettes/activerecord_actsas_error_handling_destroys_empty_assistant_messages_on_api_failure.yml diff --git a/spec/fixtures/vcr_cassettes/activerecord_actsas_error_handling_destroys_empty_assistant_messages_on_api_failure.yml b/spec/fixtures/vcr_cassettes/activerecord_actsas_error_handling_destroys_empty_assistant_messages_on_api_failure.yml deleted file mode 100644 index 2ea9bcdf4..000000000 --- a/spec/fixtures/vcr_cassettes/activerecord_actsas_error_handling_destroys_empty_assistant_messages_on_api_failure.yml +++ /dev/null @@ -1,115 +0,0 @@ ---- -http_interactions: -- request: - method: post - uri: https://api.openai.com/v1/chat/completions - body: - encoding: UTF-8 - string: '{"model":"gpt-4.1-nano","messages":[{"role":"user","content":"This - will fail"}],"stream":false,"temperature":0.7}' - headers: - User-Agent: - - Faraday v2.12.2 - Authorization: - - Bearer - Content-Type: - - application/json - Accept-Encoding: - - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 - Accept: - - "*/*" - response: - status: - code: 200 - message: OK - headers: - Date: - - Wed, 23 Jul 2025 06:53:29 GMT - Content-Type: - - application/json - Transfer-Encoding: - - chunked - Connection: - - keep-alive - Access-Control-Expose-Headers: - - X-Request-ID - Openai-Organization: - - "" - Openai-Processing-Ms: - - '870' - Openai-Project: - - proj_c1LXe0DmfaJxa0MxGDmocdjJ - Openai-Version: - - '2020-10-01' - X-Envoy-Upstream-Service-Time: - - '901' - X-Ratelimit-Limit-Requests: - - '5000' - X-Ratelimit-Limit-Tokens: - - '2000000' - X-Ratelimit-Remaining-Requests: - - '4999' - X-Ratelimit-Remaining-Tokens: - - '1999994' - X-Ratelimit-Reset-Requests: - - 12ms - X-Ratelimit-Reset-Tokens: - - 0s - X-Request-Id: - - "" - Strict-Transport-Security: - - max-age=31536000; includeSubDomains; preload - Cf-Cache-Status: - - DYNAMIC - Set-Cookie: - - "" - - "" - X-Content-Type-Options: - - nosniff - Server: - - cloudflare - Cf-Ray: - - "" - Alt-Svc: - - h3=":443"; ma=86400 - body: - encoding: ASCII-8BIT - string: | - { - "id": "chatcmpl-BwNdw4V4t4ZnrYOfeg9HuzJQEc7Vk", - "object": "chat.completion", - "created": 1753253608, - "model": "gpt-4.1-nano-2025-04-14", - "choices": [ - { - "index": 0, - "message": { - "role": "assistant", - "content": "It sounds like you're concerned that something might not work as expected. Could you please provide more details or clarify what you're referring to? I'm here to help!", - "refusal": null, - "annotations": [] - }, - "logprobs": null, - "finish_reason": "stop" - } - ], - "usage": { - "prompt_tokens": 10, - "completion_tokens": 31, - "total_tokens": 41, - "prompt_tokens_details": { - "cached_tokens": 0, - "audio_tokens": 0 - }, - "completion_tokens_details": { - "reasoning_tokens": 0, - "audio_tokens": 0, - "accepted_prediction_tokens": 0, - "rejected_prediction_tokens": 0 - } - }, - "service_tier": "default", - "system_fingerprint": null - } - recorded_at: Wed, 23 Jul 2025 06:53:29 GMT -recorded_with: VCR 6.3.1