From 31796379ec57ad354ed85b35eee2ea64ba0216da Mon Sep 17 00:00:00 2001 From: losingle Date: Tue, 31 Mar 2026 07:01:32 +0800 Subject: [PATCH 1/5] Normalize OpenAI max token params --- lib/ruby_llm/providers/openai.rb | 29 ++++++++++++ .../providers/open_ai/provider_spec.rb | 45 +++++++++++++++++++ 2 files changed, 74 insertions(+) create mode 100644 spec/ruby_llm/providers/open_ai/provider_spec.rb diff --git a/lib/ruby_llm/providers/openai.rb b/lib/ruby_llm/providers/openai.rb index 4e36b2668..0db4efecd 100644 --- a/lib/ruby_llm/providers/openai.rb +++ b/lib/ruby_llm/providers/openai.rb @@ -30,6 +30,24 @@ def maybe_normalize_temperature(temperature, model) OpenAI::Temperature.normalize(temperature, model.id) end + # rubocop:disable Metrics/ParameterLists + def complete(messages, tools:, temperature:, model:, params: {}, headers: {}, schema: nil, thinking: nil, + tool_prefs: nil, &) + super( + messages, + tools: tools, + tool_prefs: tool_prefs, + temperature: temperature, + model: model, + params: normalize_params(params), + headers: headers, + schema: schema, + thinking: thinking, + & + ) + end + # rubocop:enable Metrics/ParameterLists + class << self def capabilities OpenAI::Capabilities @@ -49,6 +67,17 @@ def configuration_requirements %i[openai_api_key] end end + + private + + def normalize_params(params) + normalized = RubyLLM::Utils.deep_symbolize_keys(params || {}) + max_tokens = normalized[:max_tokens] + + return normalized if max_tokens.nil? || normalized.key?(:max_completion_tokens) + + normalized.merge(max_completion_tokens: max_tokens) + end end end end diff --git a/spec/ruby_llm/providers/open_ai/provider_spec.rb b/spec/ruby_llm/providers/open_ai/provider_spec.rb new file mode 100644 index 000000000..e8cec72d7 --- /dev/null +++ b/spec/ruby_llm/providers/open_ai/provider_spec.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe RubyLLM::Providers::OpenAI do + include_context 'with configured RubyLLM' + + subject(:provider) { described_class.new(RubyLLM.config) } + + describe '#complete' do + let(:model) { instance_double(RubyLLM::Model::Info, id: 'gpt-4o') } + let(:response) { instance_double(Faraday::Response) } + + before do + allow(provider.connection).to receive(:post).and_return(response) + allow(provider).to receive(:parse_completion_response).with(response).and_return( + RubyLLM::Message.new(role: :assistant, content: 'ok') + ) + end + + it 'adds max_completion_tokens when max_tokens is provided' do + provider.complete( + [], + tools: {}, + temperature: nil, + model: model, + params: { max_tokens: 128 }, + headers: {} + ) + + expect(provider.connection).to have_received(:post).with( + 'chat/completions', + hash_including(max_tokens: 128, max_completion_tokens: 128) + ) + end + end + + describe '#normalize_params' do + it 'preserves an explicit max_completion_tokens value' do + normalized = provider.send(:normalize_params, max_tokens: 128, max_completion_tokens: 64) + + expect(normalized).to include(max_tokens: 128, max_completion_tokens: 64) + end + end +end \ No newline at end of file From f42371dd3bd2563e4d151a9f3e1668161fee25fe Mon Sep 17 00:00:00 2001 From: losingle Date: Tue, 31 Mar 2026 07:01:45 +0800 Subject: [PATCH 2/5] Expose provider finish reasons --- lib/ruby_llm/message.rb | 4 +- lib/ruby_llm/providers/anthropic/chat.rb | 12 ++++++ lib/ruby_llm/providers/anthropic/streaming.rb | 14 ++++++- lib/ruby_llm/providers/gemini/chat.rb | 14 +++++++ lib/ruby_llm/providers/gemini/streaming.rb | 16 +++++++- lib/ruby_llm/providers/openai/chat.rb | 10 +++++ lib/ruby_llm/providers/openai/streaming.rb | 3 +- lib/ruby_llm/stream_accumulator.rb | 3 ++ .../ruby_llm/providers/anthropic/chat_spec.rb | 15 ++++++++ .../providers/anthropic/streaming_spec.rb | 37 +++++++++++++++++++ spec/ruby_llm/providers/gemini/chat_spec.rb | 24 ++++++++++++ .../providers/gemini/streaming_spec.rb | 19 ++++++++++ spec/ruby_llm/providers/open_ai/chat_spec.rb | 24 ++++++++++++ .../providers/open_ai/streaming_spec.rb | 28 ++++++++++++++ spec/ruby_llm/stream_accumulator_spec.rb | 14 +++++++ 15 files changed, 233 insertions(+), 4 deletions(-) create mode 100644 spec/ruby_llm/providers/anthropic/streaming_spec.rb create mode 100644 spec/ruby_llm/providers/open_ai/streaming_spec.rb diff --git a/lib/ruby_llm/message.rb b/lib/ruby_llm/message.rb index eefb93e55..e7f835944 100644 --- a/lib/ruby_llm/message.rb +++ b/lib/ruby_llm/message.rb @@ -5,7 +5,7 @@ module RubyLLM class Message ROLES = %i[system user assistant tool].freeze - attr_reader :role, :model_id, :tool_calls, :tool_call_id, :raw, :thinking, :tokens + attr_reader :role, :model_id, :tool_calls, :tool_call_id, :raw, :thinking, :tokens, :finish_reason attr_writer :content def initialize(options = {}) @@ -24,6 +24,7 @@ def initialize(options = {}) ) @raw = options[:raw] @thinking = options[:thinking] + @finish_reason = options[:finish_reason] ensure_valid_role end @@ -79,6 +80,7 @@ def to_h model_id: model_id, tool_calls: tool_calls, tool_call_id: tool_call_id, + finish_reason: finish_reason, thinking: thinking&.text, thinking_signature: thinking&.signature }.merge(tokens ? tokens.to_h : {}).compact diff --git a/lib/ruby_llm/providers/anthropic/chat.rb b/lib/ruby_llm/providers/anthropic/chat.rb index 9926fe98b..18dadf165 100644 --- a/lib/ruby_llm/providers/anthropic/chat.rb +++ b/lib/ruby_llm/providers/anthropic/chat.rb @@ -133,11 +133,23 @@ def build_message(data, content, thinking, thinking_signature, tool_use_blocks, cached_tokens: cached_tokens, cache_creation_tokens: cache_creation_tokens, thinking_tokens: thinking_tokens, + finish_reason: normalize_finish_reason(data['stop_reason']), model_id: data['model'], raw: response ) end + def normalize_finish_reason(reason) + case reason + when 'end_turn', 'stop_sequence' + 'stop' + when 'max_tokens' + 'length' + else + reason + end + end + def format_message(msg, thinking: nil) thinking_enabled = thinking&.enabled? diff --git a/lib/ruby_llm/providers/anthropic/streaming.rb b/lib/ruby_llm/providers/anthropic/streaming.rb index 014a5c792..d6421e95b 100644 --- a/lib/ruby_llm/providers/anthropic/streaming.rb +++ b/lib/ruby_llm/providers/anthropic/streaming.rb @@ -26,10 +26,22 @@ def build_chunk(data) output_tokens: extract_output_tokens(data), cached_tokens: extract_cached_tokens(data), cache_creation_tokens: extract_cache_creation_tokens(data), - tool_calls: extract_tool_calls(data) + tool_calls: extract_tool_calls(data), + finish_reason: normalize_finish_reason(data.dig('delta', 'stop_reason')) ) end + def normalize_finish_reason(reason) + case reason + when 'end_turn', 'stop_sequence' + 'stop' + when 'max_tokens' + 'length' + else + reason + end + end + def extract_content_delta(data, delta_type) return data.dig('delta', 'text') if delta_type == 'text_delta' diff --git a/lib/ruby_llm/providers/gemini/chat.rb b/lib/ruby_llm/providers/gemini/chat.rb index 54fc51f72..994e3b74e 100644 --- a/lib/ruby_llm/providers/gemini/chat.rb +++ b/lib/ruby_llm/providers/gemini/chat.rb @@ -122,11 +122,25 @@ def parse_completion_response(response) output_tokens: calculate_output_tokens(data), cached_tokens: data.dig('usageMetadata', 'cachedContentTokenCount'), thinking_tokens: data.dig('usageMetadata', 'thoughtsTokenCount'), + finish_reason: normalize_finish_reason(data.dig('candidates', 0, 'finishReason')), model_id: data['modelVersion'] || response.env.url.path.split('/')[3].split(':')[0], raw: response ) end + def normalize_finish_reason(reason) + case reason + when 'STOP' + 'stop' + when 'MAX_TOKENS' + 'length' + when 'SAFETY', 'RECITATION' + 'content_filter' + else + reason + end + end + def convert_schema_to_gemini(schema) return nil unless schema diff --git a/lib/ruby_llm/providers/gemini/streaming.rb b/lib/ruby_llm/providers/gemini/streaming.rb index 526d832c6..399a3852c 100644 --- a/lib/ruby_llm/providers/gemini/streaming.rb +++ b/lib/ruby_llm/providers/gemini/streaming.rb @@ -24,7 +24,8 @@ def build_chunk(data) output_tokens: extract_output_tokens(data), cached_tokens: data.dig('usageMetadata', 'cachedContentTokenCount'), thinking_tokens: data.dig('usageMetadata', 'thoughtsTokenCount'), - tool_calls: extract_tool_calls(data) + tool_calls: extract_tool_calls(data), + finish_reason: normalize_finish_reason(data.dig('candidates', 0, 'finishReason')) ) end @@ -34,6 +35,19 @@ def extract_model_id(data) data['modelVersion'] end + def normalize_finish_reason(reason) + case reason + when 'STOP' + 'stop' + when 'MAX_TOKENS' + 'length' + when 'SAFETY', 'RECITATION' + 'content_filter' + else + reason + end + end + def extract_text_content(parts) text_parts = parts.reject { |p| p['thought'] } text = text_parts.filter_map { |p| p['text'] }.join diff --git a/lib/ruby_llm/providers/openai/chat.rb b/lib/ruby_llm/providers/openai/chat.rb index ab9552edd..d6a6065f7 100644 --- a/lib/ruby_llm/providers/openai/chat.rb +++ b/lib/ruby_llm/providers/openai/chat.rb @@ -77,11 +77,21 @@ def parse_completion_response(response) cached_tokens: cached_tokens, cache_creation_tokens: 0, thinking_tokens: thinking_tokens, + finish_reason: normalize_finish_reason(data.dig('choices', 0, 'finish_reason')), model_id: data['model'], raw: response ) end + def normalize_finish_reason(reason) + case reason + when 'tool_calls', 'function_call' + 'tool_use' + else + reason + end + end + def format_messages(messages) messages.map do |msg| { diff --git a/lib/ruby_llm/providers/openai/streaming.rb b/lib/ruby_llm/providers/openai/streaming.rb index 604251657..d16632c6f 100644 --- a/lib/ruby_llm/providers/openai/streaming.rb +++ b/lib/ruby_llm/providers/openai/streaming.rb @@ -31,7 +31,8 @@ def build_chunk(data) output_tokens: usage['completion_tokens'], cached_tokens: cached_tokens, cache_creation_tokens: 0, - thinking_tokens: usage.dig('completion_tokens_details', 'reasoning_tokens') + thinking_tokens: usage.dig('completion_tokens_details', 'reasoning_tokens'), + finish_reason: OpenAI::Chat.normalize_finish_reason(data.dig('choices', 0, 'finish_reason')) ) end diff --git a/lib/ruby_llm/stream_accumulator.rb b/lib/ruby_llm/stream_accumulator.rb index 0ae83bb15..bf0aab2ec 100644 --- a/lib/ruby_llm/stream_accumulator.rb +++ b/lib/ruby_llm/stream_accumulator.rb @@ -9,6 +9,7 @@ def initialize @content = +'' @thinking_text = +'' @thinking_signature = nil + @finish_reason = nil @tool_calls = {} @input_tokens = nil @output_tokens = nil @@ -23,6 +24,7 @@ def initialize def add(chunk) RubyLLM.logger.debug { chunk.inspect } if RubyLLM.config.log_stream_debug @model_id ||= chunk.model_id + @finish_reason = chunk.finish_reason if chunk.finish_reason handle_chunk_content(chunk) append_thinking_from_chunk(chunk) @@ -47,6 +49,7 @@ def to_message(response) ), model_id: model_id, tool_calls: tool_calls_from_stream, + finish_reason: @finish_reason, raw: response ) end diff --git a/spec/ruby_llm/providers/anthropic/chat_spec.rb b/spec/ruby_llm/providers/anthropic/chat_spec.rb index 004864050..deccf9b3c 100644 --- a/spec/ruby_llm/providers/anthropic/chat_spec.rb +++ b/spec/ruby_llm/providers/anthropic/chat_spec.rb @@ -155,5 +155,20 @@ expect(message.cached_tokens).to eq(21) expect(message.cache_creation_tokens).to eq(7) end + + it 'normalizes finish_reason on the message' do + response_body = { + 'model' => 'claude-sonnet-4-5-20250929', + 'stop_reason' => 'max_tokens', + 'content' => [{ 'type' => 'text', 'text' => 'Hi!' }], + 'usage' => {} + } + + response = instance_double(Faraday::Response, body: response_body) + + message = described_class.parse_completion_response(response) + + expect(message.finish_reason).to eq('length') + end end end diff --git a/spec/ruby_llm/providers/anthropic/streaming_spec.rb b/spec/ruby_llm/providers/anthropic/streaming_spec.rb new file mode 100644 index 000000000..11e5c1fb9 --- /dev/null +++ b/spec/ruby_llm/providers/anthropic/streaming_spec.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe RubyLLM::Providers::Anthropic::Streaming do + include_context 'with configured RubyLLM' + + let(:test_obj) do + Object.new.tap do |obj| + obj.extend(RubyLLM::Providers::Anthropic::Tools) + obj.extend(described_class) + end + end + + it 'normalizes finish_reason on streaming chunks' do + data = { + 'type' => 'message_delta', + 'delta' => { + 'type' => 'text_delta', + 'text' => 'hello', + 'stop_reason' => 'max_tokens' + } + } + + allow(test_obj).to receive(:extract_model_id).and_return('claude-sonnet-4-5') + allow(test_obj).to receive(:extract_input_tokens).and_return(nil) + allow(test_obj).to receive(:extract_output_tokens).and_return(nil) + allow(test_obj).to receive(:extract_cached_tokens).and_return(nil) + allow(test_obj).to receive(:extract_cache_creation_tokens).and_return(nil) + allow(test_obj).to receive(:extract_tool_calls).and_return(nil) + + chunk = test_obj.send(:build_chunk, data) + + expect(chunk.content).to eq('hello') + expect(chunk.finish_reason).to eq('length') + end +end \ No newline at end of file diff --git a/spec/ruby_llm/providers/gemini/chat_spec.rb b/spec/ruby_llm/providers/gemini/chat_spec.rb index ea4675262..9c7ba9d0d 100644 --- a/spec/ruby_llm/providers/gemini/chat_spec.rb +++ b/spec/ruby_llm/providers/gemini/chat_spec.rb @@ -14,6 +14,30 @@ end end + describe '#parse_completion_response' do + it 'normalizes finish_reason on the message' do + response_body = { + 'candidates' => [ + { + 'finishReason' => 'SAFETY', + 'content' => { + 'parts' => [{ 'text' => 'blocked' }] + } + } + ], + 'usageMetadata' => {}, + 'modelVersion' => 'gemini-2.5-flash' + } + + response = instance_double(Faraday::Response, body: response_body, env: instance_double(Faraday::Env)) + allow(test_obj).to receive(:extract_tool_calls).and_return(nil) + + message = test_obj.send(:parse_completion_response, response) + + expect(message.finish_reason).to eq('content_filter') + end + end + describe '#convert_schema_to_gemini' do it 'extracts inner schema from wrapper format' do # Simulate what RubyLLM::Schema.to_json_schema returns diff --git a/spec/ruby_llm/providers/gemini/streaming_spec.rb b/spec/ruby_llm/providers/gemini/streaming_spec.rb index c20110e08..9fbc3452c 100644 --- a/spec/ruby_llm/providers/gemini/streaming_spec.rb +++ b/spec/ruby_llm/providers/gemini/streaming_spec.rb @@ -36,6 +36,25 @@ expect(chunk.cached_tokens).to eq(6) end + it 'normalizes finish_reason on streaming chunks' do + data = { + 'candidates' => [ + { + 'finishReason' => 'MAX_TOKENS', + 'content' => { + 'parts' => [{ 'text' => 'hello' }] + } + } + ], + 'usageMetadata' => {}, + 'modelVersion' => 'gemini-2.5-flash' + } + + chunk = test_obj.send(:build_chunk, data) + + expect(chunk.finish_reason).to eq('length') + end + it 'correctly sums candidatesTokenCount and thoughtsTokenCount in streaming' do chat = RubyLLM.chat(model: 'gemini-2.5-flash', provider: :gemini) diff --git a/spec/ruby_llm/providers/open_ai/chat_spec.rb b/spec/ruby_llm/providers/open_ai/chat_spec.rb index e6f681299..ea6efb048 100644 --- a/spec/ruby_llm/providers/open_ai/chat_spec.rb +++ b/spec/ruby_llm/providers/open_ai/chat_spec.rb @@ -32,6 +32,30 @@ expect(message.output_tokens).to eq(4) expect(message.cache_creation_tokens).to eq(0) end + + it 'normalizes finish_reason on the message' do + response_body = { + 'model' => 'gpt-4.1-nano', + 'choices' => [ + { + 'finish_reason' => 'tool_calls', + 'message' => { + 'role' => 'assistant', + 'content' => nil, + 'tool_calls' => [] + } + } + ], + 'usage' => {} + } + + response = instance_double(Faraday::Response, body: response_body) + allow(described_class).to receive(:parse_tool_calls).and_return(nil) + + message = described_class.parse_completion_response(response) + + expect(message.finish_reason).to eq('tool_use') + end end describe '.render_payload' do diff --git a/spec/ruby_llm/providers/open_ai/streaming_spec.rb b/spec/ruby_llm/providers/open_ai/streaming_spec.rb new file mode 100644 index 000000000..76f1b06b1 --- /dev/null +++ b/spec/ruby_llm/providers/open_ai/streaming_spec.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe RubyLLM::Providers::OpenAI::Streaming do + include_context 'with configured RubyLLM' + + it 'normalizes finish_reason on streaming chunks' do + data = { + 'model' => 'gpt-4o', + 'choices' => [ + { + 'finish_reason' => 'length', + 'delta' => { + 'content' => 'hello' + } + } + ] + } + + allow(described_class).to receive(:parse_tool_calls).and_return(nil) + + chunk = described_class.build_chunk(data) + + expect(chunk.content).to eq('hello') + expect(chunk.finish_reason).to eq('length') + end +end \ No newline at end of file diff --git a/spec/ruby_llm/stream_accumulator_spec.rb b/spec/ruby_llm/stream_accumulator_spec.rb index 82a24da58..ae255be40 100644 --- a/spec/ruby_llm/stream_accumulator_spec.rb +++ b/spec/ruby_llm/stream_accumulator_spec.rb @@ -3,6 +3,20 @@ require 'spec_helper' RSpec.describe RubyLLM::StreamAccumulator do + describe '#to_message' do + it 'keeps the last non-nil finish_reason from streamed chunks' do + accumulator = described_class.new + + accumulator.add(RubyLLM::Chunk.new(role: :assistant, content: 'hel')) + accumulator.add(RubyLLM::Chunk.new(role: :assistant, content: 'lo', finish_reason: 'length')) + + message = accumulator.to_message(instance_double(Faraday::Response)) + + expect(message.content).to eq('hello') + expect(message.finish_reason).to eq('length') + end + end + describe '#add' do it 'handles tool call deltas that omit arguments' do accumulator = described_class.new From 7372acd6bfd4b248655d9bc389b2ce737e1a7a44 Mon Sep 17 00:00:00 2001 From: losingle Date: Tue, 31 Mar 2026 07:09:19 +0800 Subject: [PATCH 3/5] Fix spec lint offenses --- .../generators/chat_ui_generator_spec.rb | 12 ++--- .../providers/anthropic/streaming_spec.rb | 16 ++++--- spec/ruby_llm/providers/gemini/chat_spec.rb | 47 +++++++++---------- .../providers/open_ai/streaming_spec.rb | 2 +- ...vider_spec.rb => open_ai_provider_spec.rb} | 25 +++++++--- 5 files changed, 57 insertions(+), 45 deletions(-) rename spec/ruby_llm/providers/{open_ai/provider_spec.rb => open_ai_provider_spec.rb} (75%) diff --git a/spec/ruby_llm/generators/chat_ui_generator_spec.rb b/spec/ruby_llm/generators/chat_ui_generator_spec.rb index c9ef4d79d..512cfa990 100644 --- a/spec/ruby_llm/generators/chat_ui_generator_spec.rb +++ b/spec/ruby_llm/generators/chat_ui_generator_spec.rb @@ -134,13 +134,13 @@ expect(message_content).to include('acts_as_message') # Check broadcasting setup - expect(message_content).to include(%q(broadcasts_to ->(message) { "chat_#{message.chat_id}" })) + expect(message_content).to include("broadcasts_to ->(message) { \"chat_\#{message.chat_id}\" }") expect(message_content).to include('inserts_by: :append') # Check broadcast_append_chunk method expect(message_content).to include('def broadcast_append_chunk(content)') - expect(message_content).to include(%q(broadcast_append_to "chat_#{chat_id}")) - expect(message_content).to include(%q(target: "message_#{id}_content")) + expect(message_content).to include("broadcast_append_to \"chat_\#{chat_id}\"") + expect(message_content).to include("target: \"message_\#{id}_content\"") expect(message_content).to include('content: ERB::Util.html_escape(content.to_s)') end end @@ -318,13 +318,13 @@ expect(message_content).to include("model: :llm_model, model_class: 'Llm::Model'") # Check broadcasting setup - expect(message_content).to include(%q(broadcasts_to ->(llm_message) { "llm_chat_#{llm_message.llm_chat_id}" })) + expect(message_content).to include("broadcasts_to ->(llm_message) { \"llm_chat_\#{llm_message.llm_chat_id}\" }") expect(message_content).to include('inserts_by: :append') # Check broadcast_append_chunk method expect(message_content).to include('def broadcast_append_chunk(content)') - expect(message_content).to include(%q(broadcast_append_to "llm_chat_#{llm_chat_id}")) - expect(message_content).to include(%q(target: "llm_message_#{id}_content")) + expect(message_content).to include("broadcast_append_to \"llm_chat_\#{llm_chat_id}\"") + expect(message_content).to include("target: \"llm_message_\#{id}_content\"") expect(message_content).to include('content: ERB::Util.html_escape(content.to_s)') end end diff --git a/spec/ruby_llm/providers/anthropic/streaming_spec.rb b/spec/ruby_llm/providers/anthropic/streaming_spec.rb index 11e5c1fb9..84268d692 100644 --- a/spec/ruby_llm/providers/anthropic/streaming_spec.rb +++ b/spec/ruby_llm/providers/anthropic/streaming_spec.rb @@ -22,16 +22,18 @@ } } - allow(test_obj).to receive(:extract_model_id).and_return('claude-sonnet-4-5') - allow(test_obj).to receive(:extract_input_tokens).and_return(nil) - allow(test_obj).to receive(:extract_output_tokens).and_return(nil) - allow(test_obj).to receive(:extract_cached_tokens).and_return(nil) - allow(test_obj).to receive(:extract_cache_creation_tokens).and_return(nil) - allow(test_obj).to receive(:extract_tool_calls).and_return(nil) + allow(test_obj).to receive_messages( + extract_model_id: 'claude-sonnet-4-5', + extract_input_tokens: nil, + extract_output_tokens: nil, + extract_cached_tokens: nil, + extract_cache_creation_tokens: nil, + extract_tool_calls: nil + ) chunk = test_obj.send(:build_chunk, data) expect(chunk.content).to eq('hello') expect(chunk.finish_reason).to eq('length') end -end \ No newline at end of file +end diff --git a/spec/ruby_llm/providers/gemini/chat_spec.rb b/spec/ruby_llm/providers/gemini/chat_spec.rb index 9c7ba9d0d..b9ce5c2f2 100644 --- a/spec/ruby_llm/providers/gemini/chat_spec.rb +++ b/spec/ruby_llm/providers/gemini/chat_spec.rb @@ -14,30 +14,6 @@ end end - describe '#parse_completion_response' do - it 'normalizes finish_reason on the message' do - response_body = { - 'candidates' => [ - { - 'finishReason' => 'SAFETY', - 'content' => { - 'parts' => [{ 'text' => 'blocked' }] - } - } - ], - 'usageMetadata' => {}, - 'modelVersion' => 'gemini-2.5-flash' - } - - response = instance_double(Faraday::Response, body: response_body, env: instance_double(Faraday::Env)) - allow(test_obj).to receive(:extract_tool_calls).and_return(nil) - - message = test_obj.send(:parse_completion_response, response) - - expect(message.finish_reason).to eq('content_filter') - end - end - describe '#convert_schema_to_gemini' do it 'extracts inner schema from wrapper format' do # Simulate what RubyLLM::Schema.to_json_schema returns @@ -549,6 +525,29 @@ end describe '#parse_completion_response' do + it 'normalizes finish_reason on the message' do + response = Struct.new(:body, :env).new( + { + 'candidates' => [ + { + 'finishReason' => 'SAFETY', + 'content' => { + 'parts' => [{ 'text' => 'blocked' }] + } + } + ], + 'usageMetadata' => {}, + 'modelVersion' => 'gemini-2.5-flash' + }, + Struct.new(:url).new(Struct.new(:path).new('/v1/models/gemini-2.5-flash:generateContent')) + ) + + provider = RubyLLM::Providers::Gemini.new(RubyLLM.config) + message = provider.send(:parse_completion_response, response) + + expect(message.finish_reason).to eq('content_filter') + end + it 'keeps thought-only parts out of assistant content' do response = Struct.new(:body, :env).new( { diff --git a/spec/ruby_llm/providers/open_ai/streaming_spec.rb b/spec/ruby_llm/providers/open_ai/streaming_spec.rb index 76f1b06b1..80bc1cbd2 100644 --- a/spec/ruby_llm/providers/open_ai/streaming_spec.rb +++ b/spec/ruby_llm/providers/open_ai/streaming_spec.rb @@ -25,4 +25,4 @@ expect(chunk.content).to eq('hello') expect(chunk.finish_reason).to eq('length') end -end \ No newline at end of file +end diff --git a/spec/ruby_llm/providers/open_ai/provider_spec.rb b/spec/ruby_llm/providers/open_ai_provider_spec.rb similarity index 75% rename from spec/ruby_llm/providers/open_ai/provider_spec.rb rename to spec/ruby_llm/providers/open_ai_provider_spec.rb index e8cec72d7..5c9eeb261 100644 --- a/spec/ruby_llm/providers/open_ai/provider_spec.rb +++ b/spec/ruby_llm/providers/open_ai_provider_spec.rb @@ -3,19 +3,30 @@ require 'spec_helper' RSpec.describe RubyLLM::Providers::OpenAI do - include_context 'with configured RubyLLM' - subject(:provider) { described_class.new(RubyLLM.config) } + include_context 'with configured RubyLLM' + describe '#complete' do let(:model) { instance_double(RubyLLM::Model::Info, id: 'gpt-4o') } - let(:response) { instance_double(Faraday::Response) } + let(:response_body) do + { + 'model' => 'gpt-4o', + 'choices' => [ + { + 'message' => { + 'role' => 'assistant', + 'content' => 'ok' + } + } + ], + 'usage' => {} + } + end + let(:response) { instance_double(Faraday::Response, body: response_body) } before do allow(provider.connection).to receive(:post).and_return(response) - allow(provider).to receive(:parse_completion_response).with(response).and_return( - RubyLLM::Message.new(role: :assistant, content: 'ok') - ) end it 'adds max_completion_tokens when max_tokens is provided' do @@ -42,4 +53,4 @@ expect(normalized).to include(max_tokens: 128, max_completion_tokens: 64) end end -end \ No newline at end of file +end From 66c4c360f626649a12b3f2fe9c3868f18ba451f2 Mon Sep 17 00:00:00 2001 From: losingle Date: Tue, 31 Mar 2026 21:43:29 +0800 Subject: [PATCH 4/5] =?UTF-8?q?=E6=9B=B4=E6=96=B0=20chat.rb?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- lib/ruby_llm/providers/anthropic/chat.rb | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/lib/ruby_llm/providers/anthropic/chat.rb b/lib/ruby_llm/providers/anthropic/chat.rb index 18dadf165..76146e239 100644 --- a/lib/ruby_llm/providers/anthropic/chat.rb +++ b/lib/ruby_llm/providers/anthropic/chat.rb @@ -139,15 +139,14 @@ def build_message(data, content, thinking, thinking_signature, tool_use_blocks, ) end + FINISH_REASON_MAP = { + 'end_turn' => 'stop', + 'stop_sequence' => 'stop', + 'max_tokens' => 'length' + }.freeze + def normalize_finish_reason(reason) - case reason - when 'end_turn', 'stop_sequence' - 'stop' - when 'max_tokens' - 'length' - else - reason - end + FINISH_REASON_MAP.fetch(reason, reason) end def format_message(msg, thinking: nil) From 3c7fa3c72ad285e78e6290088d66542f453a648c Mon Sep 17 00:00:00 2001 From: losingle Date: Tue, 31 Mar 2026 21:43:40 +0800 Subject: [PATCH 5/5] =?UTF-8?q?=E6=9B=B4=E6=96=B0=20chat.rb?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- lib/ruby_llm/providers/gemini/chat.rb | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/lib/ruby_llm/providers/gemini/chat.rb b/lib/ruby_llm/providers/gemini/chat.rb index 994e3b74e..9837887fc 100644 --- a/lib/ruby_llm/providers/gemini/chat.rb +++ b/lib/ruby_llm/providers/gemini/chat.rb @@ -128,17 +128,15 @@ def parse_completion_response(response) ) end + FINISH_REASON_MAP = { + 'STOP' => 'stop', + 'MAX_TOKENS' => 'length', + 'SAFETY' => 'content_filter', + 'RECITATION' => 'content_filter' + }.freeze + def normalize_finish_reason(reason) - case reason - when 'STOP' - 'stop' - when 'MAX_TOKENS' - 'length' - when 'SAFETY', 'RECITATION' - 'content_filter' - else - reason - end + FINISH_REASON_MAP.fetch(reason, reason) end def convert_schema_to_gemini(schema)