Skip to content

Commit 80a459f

Browse files
committed
fix(rails): Clean up empty assistant message on API failure in acts_as_chat
Previously, API errors during a chat turn would leave an orphaned, empty assistant message record. This caused issues, notably with Gemini rejecting subsequent requests containing empty messages. Fixes #118
1 parent d4d3ed6 commit 80a459f

File tree

5 files changed

+69
-23
lines changed

5 files changed

+69
-23
lines changed

docs/guides/rails.md

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -139,10 +139,20 @@ chat_record = Chat.create!(model_id: 'gpt-4.1-nano', user: current_user)
139139
# The `model_id` should typically be a valid identifier known to RubyLLM.
140140
# See the [Working with Models Guide]({% link guides/models.md %}) for details.
141141

142-
# Ask a question. This automatically:
143-
# 1. Saves the user message ("What is the capital...")
144-
# 2. Makes the API call with history
145-
# 3. Saves the assistant message (the response)
142+
# Ask a question. This automatically handles persistence:
143+
# 1. Saves the user message ("What is the capital of France?") to the database.
144+
# 2. Creates an *empty* assistant message record. This allows real-time UI
145+
# updates (e.g., using Turbo Streams with `after_create_commit` on the
146+
# Message model) by providing a target DOM ID *before* the API call.
147+
# 3. Makes the API call to the provider with the conversation history.
148+
# 4. **On Success:** Updates the previously created assistant message record
149+
# with the actual content, token counts, and any tool call information.
150+
# 5. **On Failure:** If the API call raises an error (e.g., network issue,
151+
# invalid key, provider error), the empty assistant message record created
152+
# in step 2 is **automatically destroyed**. This prevents orphaned empty
153+
# messages in your database.
154+
# 6. Returns the final `RubyLLM::Message` object on success, or raises the
155+
# `RubyLLM::Error` on failure.
146156
response = chat_record.ask "What is the capital of France?"
147157

148158
# `response` is the RubyLLM::Message object from the API call.

lib/ruby_llm/active_record/acts_as.rb

Lines changed: 24 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,7 @@ def acts_as_chat(message_class: 'Message', tool_call_class: 'ToolCall')
2020
class_name: @message_class,
2121
dependent: :destroy
2222

23-
delegate :complete,
24-
:add_message,
23+
delegate :add_message,
2524
to: :to_llm
2625
end
2726

@@ -94,40 +93,50 @@ def with_instructions(instructions, replace: false)
9493
self
9594
end
9695

97-
def with_tool(tool)
98-
to_llm.with_tool(tool)
96+
def with_tool(...)
97+
to_llm.with_tool(...)
9998
self
10099
end
101100

102-
def with_tools(*tools)
103-
to_llm.with_tools(*tools)
101+
def with_tools(...)
102+
to_llm.with_tools(...)
104103
self
105104
end
106105

107-
def with_model(model_id, provider: nil)
108-
to_llm.with_model(model_id, provider: provider)
106+
def with_model(...)
107+
to_llm.with_model(...)
109108
self
110109
end
111110

112-
def with_temperature(temperature)
113-
to_llm.with_temperature(temperature)
111+
def with_temperature(...)
112+
to_llm.with_temperature(...)
114113
self
115114
end
116115

117-
def on_new_message(&)
118-
to_llm.on_new_message(&)
116+
def on_new_message(...)
117+
to_llm.on_new_message(...)
119118
self
120119
end
121120

122-
def on_end_message(&)
123-
to_llm.on_end_message(&)
121+
def on_end_message(...)
122+
to_llm.on_end_message(...)
124123
self
125124
end
126125

127126
def ask(message, &)
128127
message = { role: :user, content: message }
129128
messages.create!(**message)
130-
to_llm.complete(&)
129+
complete(&)
130+
end
131+
132+
def complete(...)
133+
to_llm.complete(...)
134+
rescue RubyLLM::Error => e
135+
if @message&.persisted? && @message.content.blank?
136+
RubyLLM.logger.debug "RubyLLM: API call failed, destroying message: #{@message.id}"
137+
@message.destroy
138+
end
139+
raise e
131140
end
132141

133142
alias say ask

lib/ruby_llm/chat.rb

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,9 +31,9 @@ def initialize(model: nil, provider: nil, assume_model_exists: false, context: n
3131
}
3232
end
3333

34-
def ask(message = nil, with: {}, &block)
34+
def ask(message = nil, with: {}, &)
3535
add_message role: :user, content: Content.new(message, with)
36-
complete(&block)
36+
complete(&)
3737
end
3838

3939
alias say ask

lib/ruby_llm/providers/openai/chat.rb

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,12 @@ module Providers
55
module OpenAI
66
# Chat methods of the OpenAI API integration
77
module Chat
8-
module_function
9-
108
def completion_url
119
'chat/completions'
1210
end
1311

12+
module_function
13+
1414
def render_payload(messages, tools:, temperature:, model:, stream: false) # rubocop:disable Metrics/MethodLength
1515
{
1616
model: model,

spec/ruby_llm/active_record/acts_as_spec.rb

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,4 +170,31 @@ def execute(expression:)
170170
expect(chat.messages.find_by(role: 'system').content).to eq('Be awesome')
171171
end
172172
end
173+
174+
describe 'acts_as_chat error handling' do
175+
let!(:chat_record) { Chat.create!(model_id: 'gpt-4.1-nano') }
176+
let(:provider_instance) { RubyLLM::Provider.for(chat_record.model_id) }
177+
let(:api_base) { provider_instance.api_base(RubyLLM.config) }
178+
let(:completion_url_regex) { %r{#{api_base}/#{provider_instance.completion_url}} }
179+
180+
before do
181+
stub_request(:post, completion_url_regex)
182+
.to_return(
183+
status: 500,
184+
body: { error: { message: 'API go boom' } }.to_json,
185+
headers: { 'Content-Type' => 'application/json' }
186+
)
187+
end
188+
189+
it 'destroys the empty assistant message record on API failure' do # rubocop:disable RSpec/ExampleLength,RSpec/MultipleExpectations
190+
expect do
191+
chat_record.ask('This one will fail')
192+
end.to raise_error(RubyLLM::ServerError, /API go boom/)
193+
expect(Message.where(chat_id: chat_record.id).count).to eq(1)
194+
remaining_message = Message.find_by(chat_id: chat_record.id)
195+
expect(remaining_message.role).to eq('user')
196+
expect(remaining_message.content).to eq('This one will fail')
197+
expect(Message.where(chat_id: chat_record.id, role: 'assistant').count).to eq(0)
198+
end
199+
end
173200
end

0 commit comments

Comments
 (0)