Skip to content

Introduce support of Responses API #290

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions lib/ruby_llm.rb
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,10 @@ def chat(...)
Chat.new(...)
end

def response(...)
Response.new(...)
end

def embed(...)
Embedding.embed(...)
end
Expand Down
2 changes: 1 addition & 1 deletion lib/ruby_llm/active_record/acts_as.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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}"
Expand Down
153 changes: 4 additions & 149 deletions lib/ruby_llm/chat.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm moving most of the Chat implementation to a base class Conversation so both Chat and Conversation inherit from the logic


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
160 changes: 160 additions & 0 deletions lib/ruby_llm/conversation.rb
Original file line number Diff line number Diff line change
@@ -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
Loading