dify_llm 1.9.2 → 1.14.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (168) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +27 -8
  3. data/lib/generators/ruby_llm/agent/agent_generator.rb +36 -0
  4. data/lib/generators/ruby_llm/agent/templates/agent.rb.tt +6 -0
  5. data/lib/generators/ruby_llm/agent/templates/instructions.txt.erb.tt +0 -0
  6. data/lib/generators/ruby_llm/chat_ui/chat_ui_generator.rb +110 -41
  7. data/lib/generators/ruby_llm/chat_ui/templates/controllers/chats_controller.rb.tt +14 -15
  8. data/lib/generators/ruby_llm/chat_ui/templates/controllers/messages_controller.rb.tt +8 -11
  9. data/lib/generators/ruby_llm/chat_ui/templates/controllers/models_controller.rb.tt +2 -2
  10. data/lib/generators/ruby_llm/chat_ui/templates/helpers/messages_helper.rb.tt +25 -0
  11. data/lib/generators/ruby_llm/chat_ui/templates/jobs/chat_response_job.rb.tt +2 -2
  12. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/chats/_chat.html.erb.tt +16 -0
  13. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/chats/_form.html.erb.tt +31 -0
  14. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/chats/index.html.erb.tt +31 -0
  15. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/chats/new.html.erb.tt +9 -0
  16. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/chats/show.html.erb.tt +27 -0
  17. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/_assistant.html.erb.tt +14 -0
  18. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/_content.html.erb.tt +1 -0
  19. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/_error.html.erb.tt +13 -0
  20. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/_form.html.erb.tt +23 -0
  21. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/_system.html.erb.tt +10 -0
  22. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/_tool.html.erb.tt +2 -0
  23. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/_tool_calls.html.erb.tt +4 -0
  24. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/_user.html.erb.tt +14 -0
  25. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/tool_calls/_default.html.erb.tt +13 -0
  26. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/messages/tool_results/_default.html.erb.tt +21 -0
  27. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/models/_model.html.erb.tt +17 -0
  28. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/models/index.html.erb.tt +40 -0
  29. data/lib/generators/ruby_llm/chat_ui/templates/tailwind/views/models/show.html.erb.tt +27 -0
  30. data/lib/generators/ruby_llm/chat_ui/templates/views/chats/_chat.html.erb.tt +2 -2
  31. data/lib/generators/ruby_llm/chat_ui/templates/views/chats/_form.html.erb.tt +2 -2
  32. data/lib/generators/ruby_llm/chat_ui/templates/views/chats/index.html.erb.tt +19 -7
  33. data/lib/generators/ruby_llm/chat_ui/templates/views/chats/new.html.erb.tt +1 -1
  34. data/lib/generators/ruby_llm/chat_ui/templates/views/chats/show.html.erb.tt +5 -3
  35. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_assistant.html.erb.tt +9 -0
  36. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_content.html.erb.tt +1 -1
  37. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_error.html.erb.tt +8 -0
  38. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_form.html.erb.tt +1 -1
  39. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_system.html.erb.tt +6 -0
  40. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_tool.html.erb.tt +2 -0
  41. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_tool_calls.html.erb.tt +4 -7
  42. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_user.html.erb.tt +9 -0
  43. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/create.turbo_stream.erb.tt +5 -7
  44. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/tool_calls/_default.html.erb.tt +8 -0
  45. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/tool_results/_default.html.erb.tt +16 -0
  46. data/lib/generators/ruby_llm/chat_ui/templates/views/models/_model.html.erb.tt +11 -12
  47. data/lib/generators/ruby_llm/chat_ui/templates/views/models/index.html.erb.tt +27 -17
  48. data/lib/generators/ruby_llm/chat_ui/templates/views/models/show.html.erb.tt +3 -4
  49. data/lib/generators/ruby_llm/generator_helpers.rb +37 -17
  50. data/lib/generators/ruby_llm/install/install_generator.rb +22 -18
  51. data/lib/generators/ruby_llm/install/templates/create_chats_migration.rb.tt +1 -1
  52. data/lib/generators/ruby_llm/install/templates/create_messages_migration.rb.tt +4 -1
  53. data/lib/generators/ruby_llm/install/templates/create_models_migration.rb.tt +4 -10
  54. data/lib/generators/ruby_llm/install/templates/create_tool_calls_migration.rb.tt +2 -1
  55. data/lib/generators/ruby_llm/install/templates/initializer.rb.tt +2 -2
  56. data/lib/generators/ruby_llm/schema/schema_generator.rb +26 -0
  57. data/lib/generators/ruby_llm/schema/templates/schema.rb.tt +2 -0
  58. data/lib/generators/ruby_llm/tool/templates/tool.rb.tt +9 -0
  59. data/lib/generators/ruby_llm/tool/templates/tool_call.html.erb.tt +13 -0
  60. data/lib/generators/ruby_llm/tool/templates/tool_result.html.erb.tt +13 -0
  61. data/lib/generators/ruby_llm/tool/tool_generator.rb +96 -0
  62. data/lib/generators/ruby_llm/upgrade_to_v1_10/templates/add_v1_10_message_columns.rb.tt +19 -0
  63. data/lib/generators/ruby_llm/upgrade_to_v1_10/upgrade_to_v1_10_generator.rb +50 -0
  64. data/lib/generators/ruby_llm/upgrade_to_v1_14/templates/add_v1_14_tool_call_columns.rb.tt +7 -0
  65. data/lib/generators/ruby_llm/upgrade_to_v1_14/upgrade_to_v1_14_generator.rb +49 -0
  66. data/lib/generators/ruby_llm/upgrade_to_v1_7/upgrade_to_v1_7_generator.rb +2 -4
  67. data/lib/generators/ruby_llm/upgrade_to_v1_9/upgrade_to_v1_9_generator.rb +1 -1
  68. data/lib/ruby_llm/active_record/acts_as.rb +10 -4
  69. data/lib/ruby_llm/active_record/acts_as_legacy.rb +132 -27
  70. data/lib/ruby_llm/active_record/chat_methods.rb +132 -28
  71. data/lib/ruby_llm/active_record/message_methods.rb +58 -8
  72. data/lib/ruby_llm/active_record/model_methods.rb +1 -1
  73. data/lib/ruby_llm/active_record/payload_helpers.rb +26 -0
  74. data/lib/ruby_llm/active_record/tool_call_methods.rb +15 -0
  75. data/lib/ruby_llm/agent.rb +365 -0
  76. data/lib/ruby_llm/aliases.json +106 -61
  77. data/lib/ruby_llm/attachment.rb +8 -3
  78. data/lib/ruby_llm/chat.rb +150 -22
  79. data/lib/ruby_llm/configuration.rb +65 -65
  80. data/lib/ruby_llm/connection.rb +11 -7
  81. data/lib/ruby_llm/content.rb +6 -2
  82. data/lib/ruby_llm/error.rb +37 -1
  83. data/lib/ruby_llm/message.rb +43 -15
  84. data/lib/ruby_llm/model/info.rb +15 -13
  85. data/lib/ruby_llm/models.json +25039 -12260
  86. data/lib/ruby_llm/models.rb +185 -24
  87. data/lib/ruby_llm/provider.rb +26 -4
  88. data/lib/ruby_llm/providers/anthropic/capabilities.rb +5 -119
  89. data/lib/ruby_llm/providers/anthropic/chat.rb +149 -17
  90. data/lib/ruby_llm/providers/anthropic/media.rb +2 -2
  91. data/lib/ruby_llm/providers/anthropic/models.rb +3 -9
  92. data/lib/ruby_llm/providers/anthropic/streaming.rb +25 -1
  93. data/lib/ruby_llm/providers/anthropic/tools.rb +20 -0
  94. data/lib/ruby_llm/providers/anthropic.rb +5 -1
  95. data/lib/ruby_llm/providers/azure/chat.rb +29 -0
  96. data/lib/ruby_llm/providers/azure/embeddings.rb +24 -0
  97. data/lib/ruby_llm/providers/azure/media.rb +45 -0
  98. data/lib/ruby_llm/providers/azure/models.rb +14 -0
  99. data/lib/ruby_llm/providers/azure.rb +148 -0
  100. data/lib/ruby_llm/providers/bedrock/auth.rb +122 -0
  101. data/lib/ruby_llm/providers/bedrock/chat.rb +357 -28
  102. data/lib/ruby_llm/providers/bedrock/media.rb +62 -33
  103. data/lib/ruby_llm/providers/bedrock/models.rb +104 -65
  104. data/lib/ruby_llm/providers/bedrock/streaming.rb +309 -8
  105. data/lib/ruby_llm/providers/bedrock.rb +69 -52
  106. data/lib/ruby_llm/providers/deepseek/capabilities.rb +4 -114
  107. data/lib/ruby_llm/providers/deepseek.rb +5 -1
  108. data/lib/ruby_llm/providers/dify/chat.rb +82 -7
  109. data/lib/ruby_llm/providers/dify/media.rb +2 -2
  110. data/lib/ruby_llm/providers/dify/streaming.rb +26 -4
  111. data/lib/ruby_llm/providers/dify.rb +4 -0
  112. data/lib/ruby_llm/providers/gemini/capabilities.rb +45 -207
  113. data/lib/ruby_llm/providers/gemini/chat.rb +88 -6
  114. data/lib/ruby_llm/providers/gemini/images.rb +1 -1
  115. data/lib/ruby_llm/providers/gemini/models.rb +2 -4
  116. data/lib/ruby_llm/providers/gemini/streaming.rb +34 -2
  117. data/lib/ruby_llm/providers/gemini/tools.rb +35 -3
  118. data/lib/ruby_llm/providers/gemini.rb +4 -0
  119. data/lib/ruby_llm/providers/gpustack/capabilities.rb +20 -0
  120. data/lib/ruby_llm/providers/gpustack/chat.rb +1 -1
  121. data/lib/ruby_llm/providers/gpustack.rb +8 -0
  122. data/lib/ruby_llm/providers/mistral/capabilities.rb +8 -0
  123. data/lib/ruby_llm/providers/mistral/chat.rb +59 -1
  124. data/lib/ruby_llm/providers/mistral.rb +4 -0
  125. data/lib/ruby_llm/providers/ollama/capabilities.rb +20 -0
  126. data/lib/ruby_llm/providers/ollama/chat.rb +1 -1
  127. data/lib/ruby_llm/providers/ollama.rb +11 -1
  128. data/lib/ruby_llm/providers/openai/capabilities.rb +96 -192
  129. data/lib/ruby_llm/providers/openai/chat.rb +101 -7
  130. data/lib/ruby_llm/providers/openai/media.rb +5 -2
  131. data/lib/ruby_llm/providers/openai/models.rb +2 -4
  132. data/lib/ruby_llm/providers/openai/streaming.rb +11 -3
  133. data/lib/ruby_llm/providers/openai/temperature.rb +28 -0
  134. data/lib/ruby_llm/providers/openai/tools.rb +27 -2
  135. data/lib/ruby_llm/providers/openai.rb +11 -1
  136. data/lib/ruby_llm/providers/openrouter/chat.rb +168 -0
  137. data/lib/ruby_llm/providers/openrouter/images.rb +69 -0
  138. data/lib/ruby_llm/providers/openrouter/streaming.rb +74 -0
  139. data/lib/ruby_llm/providers/openrouter.rb +37 -1
  140. data/lib/ruby_llm/providers/perplexity/capabilities.rb +34 -99
  141. data/lib/ruby_llm/providers/perplexity/models.rb +12 -14
  142. data/lib/ruby_llm/providers/perplexity.rb +4 -0
  143. data/lib/ruby_llm/providers/vertexai/models.rb +1 -1
  144. data/lib/ruby_llm/providers/vertexai.rb +23 -7
  145. data/lib/ruby_llm/providers/xai/chat.rb +15 -0
  146. data/lib/ruby_llm/providers/xai/models.rb +75 -0
  147. data/lib/ruby_llm/providers/xai.rb +32 -0
  148. data/lib/ruby_llm/stream_accumulator.rb +120 -18
  149. data/lib/ruby_llm/streaming.rb +60 -57
  150. data/lib/ruby_llm/thinking.rb +49 -0
  151. data/lib/ruby_llm/tokens.rb +47 -0
  152. data/lib/ruby_llm/tool.rb +48 -3
  153. data/lib/ruby_llm/tool_call.rb +6 -3
  154. data/lib/ruby_llm/version.rb +1 -1
  155. data/lib/ruby_llm.rb +14 -8
  156. data/lib/tasks/models.rake +61 -22
  157. data/lib/tasks/release.rake +1 -1
  158. data/lib/tasks/ruby_llm.rake +9 -1
  159. data/lib/tasks/vcr.rake +33 -1
  160. metadata +67 -16
  161. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_message.html.erb.tt +0 -13
  162. data/lib/ruby_llm/providers/bedrock/capabilities.rb +0 -167
  163. data/lib/ruby_llm/providers/bedrock/signing.rb +0 -831
  164. data/lib/ruby_llm/providers/bedrock/streaming/base.rb +0 -51
  165. data/lib/ruby_llm/providers/bedrock/streaming/content_extraction.rb +0 -71
  166. data/lib/ruby_llm/providers/bedrock/streaming/message_processing.rb +0 -67
  167. data/lib/ruby_llm/providers/bedrock/streaming/payload_processing.rb +0 -80
  168. data/lib/ruby_llm/providers/bedrock/streaming/prelude_handling.rb +0 -78
@@ -10,13 +10,22 @@ module RubyLLM
10
10
  include VertexAI::Models
11
11
  include VertexAI::Transcription
12
12
 
13
+ SCOPES = [
14
+ 'https://www.googleapis.com/auth/cloud-platform',
15
+ 'https://www.googleapis.com/auth/generative-language.retriever'
16
+ ].freeze
17
+
13
18
  def initialize(config)
14
19
  super
15
20
  @authorizer = nil
16
21
  end
17
22
 
18
23
  def api_base
19
- "https://#{@config.vertexai_location}-aiplatform.googleapis.com/v1beta1"
24
+ if @config.vertexai_location.to_s == 'global'
25
+ 'https://aiplatform.googleapis.com/v1beta1'
26
+ else
27
+ "https://#{@config.vertexai_location}-aiplatform.googleapis.com/v1beta1"
28
+ end
20
29
  end
21
30
 
22
31
  def headers
@@ -31,6 +40,10 @@ module RubyLLM
31
40
  end
32
41
 
33
42
  class << self
43
+ def configuration_options
44
+ %i[vertexai_project_id vertexai_location vertexai_service_account_key]
45
+ end
46
+
34
47
  def configuration_requirements
35
48
  %i[vertexai_project_id vertexai_location]
36
49
  end
@@ -40,12 +53,15 @@ module RubyLLM
40
53
 
41
54
  def initialize_authorizer
42
55
  require 'googleauth'
43
- @authorizer = ::Google::Auth.get_application_default(
44
- scope: [
45
- 'https://www.googleapis.com/auth/cloud-platform',
46
- 'https://www.googleapis.com/auth/generative-language.retriever'
47
- ]
48
- )
56
+ @authorizer =
57
+ if @config.vertexai_service_account_key
58
+ ::Google::Auth::ServiceAccountCredentials.make_creds(
59
+ json_key_io: StringIO.new(@config.vertexai_service_account_key),
60
+ scope: SCOPES
61
+ )
62
+ else
63
+ ::Google::Auth.get_application_default(SCOPES)
64
+ end
49
65
  rescue LoadError
50
66
  raise Error,
51
67
  'The googleauth gem ~> 1.15 is required for Vertex AI. Please add it to your Gemfile: gem "googleauth"'
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Providers
5
+ class XAI
6
+ # Chat implementation for xAI
7
+ # https://docs.x.ai/docs/api-reference#chat-completions
8
+ module Chat
9
+ def format_role(role)
10
+ role.to_s
11
+ end
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Providers
5
+ class XAI
6
+ # Models metadata for xAI list models.
7
+ module Models
8
+ module_function
9
+
10
+ IMAGE_MODELS = %w[grok-2-image-1212].freeze
11
+ VISION_MODELS = %w[
12
+ grok-2-vision-1212
13
+ grok-4-0709
14
+ grok-4-fast-non-reasoning
15
+ grok-4-fast-reasoning
16
+ grok-4-1-fast-non-reasoning
17
+ grok-4-1-fast-reasoning
18
+ ].freeze
19
+ REASONING_MODELS = %w[
20
+ grok-3-mini
21
+ grok-4-0709
22
+ grok-4-fast-reasoning
23
+ grok-4-1-fast-reasoning
24
+ grok-code-fast-1
25
+ ].freeze
26
+
27
+ def parse_list_models_response(response, slug, _capabilities)
28
+ Array(response.body['data']).map do |model_data|
29
+ model_id = model_data['id']
30
+
31
+ Model::Info.new(
32
+ id: model_id,
33
+ name: format_display_name(model_id),
34
+ provider: slug,
35
+ family: 'grok',
36
+ created_at: model_data['created'] ? Time.at(model_data['created']) : nil,
37
+ context_window: nil,
38
+ max_output_tokens: nil,
39
+ modalities: modalities_for(model_id),
40
+ capabilities: capabilities_for(model_id),
41
+ pricing: {},
42
+ metadata: {
43
+ object: model_data['object'],
44
+ owned_by: model_data['owned_by']
45
+ }.compact
46
+ )
47
+ end
48
+ end
49
+
50
+ def modalities_for(model_id)
51
+ if IMAGE_MODELS.include?(model_id)
52
+ { input: ['text'], output: ['image'] }
53
+ else
54
+ input = ['text']
55
+ input << 'image' if VISION_MODELS.include?(model_id)
56
+ { input: input, output: ['text'] }
57
+ end
58
+ end
59
+
60
+ def capabilities_for(model_id)
61
+ return [] if IMAGE_MODELS.include?(model_id)
62
+
63
+ capabilities = %w[streaming function_calling structured_output]
64
+ capabilities << 'reasoning' if REASONING_MODELS.include?(model_id)
65
+ capabilities << 'vision' if VISION_MODELS.include?(model_id)
66
+ capabilities
67
+ end
68
+
69
+ def format_display_name(model_id)
70
+ model_id.tr('-', ' ').split.map(&:capitalize).join(' ')
71
+ end
72
+ end
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Providers
5
+ # xAI API integration
6
+ class XAI < OpenAI
7
+ include XAI::Chat
8
+ include XAI::Models
9
+
10
+ def api_base
11
+ 'https://api.x.ai/v1'
12
+ end
13
+
14
+ def headers
15
+ {
16
+ 'Authorization' => "Bearer #{@config.xai_api_key}",
17
+ 'Content-Type' => 'application/json'
18
+ }
19
+ end
20
+
21
+ class << self
22
+ def configuration_options
23
+ %i[xai_api_key]
24
+ end
25
+
26
+ def configuration_requirements
27
+ %i[xai_api_key]
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
@@ -7,40 +7,48 @@ module RubyLLM
7
7
 
8
8
  def initialize
9
9
  @content = +''
10
+ @thinking_text = +''
11
+ @thinking_signature = nil
10
12
  @tool_calls = {}
11
13
  @input_tokens = nil
12
14
  @output_tokens = nil
13
15
  @cached_tokens = nil
14
16
  @cache_creation_tokens = nil
17
+ @thinking_tokens = nil
18
+ @inside_think_tag = false
19
+ @pending_think_tag = +''
15
20
  @latest_tool_call_id = nil
16
21
  end
17
22
 
18
23
  def add(chunk)
19
- RubyLLM.logger.debug chunk.inspect if RubyLLM.config.log_stream_debug
24
+ RubyLLM.logger.debug { chunk.inspect } if RubyLLM.config.log_stream_debug
20
25
  @model_id ||= chunk.model_id
21
26
  @conversation_id ||= chunk.conversation_id
22
27
 
23
- if chunk.tool_call?
24
- accumulate_tool_calls chunk.tool_calls
25
- else
26
- @content << (chunk.content || '')
27
- end
28
-
28
+ handle_chunk_content(chunk)
29
+ append_thinking_from_chunk(chunk)
29
30
  count_tokens chunk
30
- RubyLLM.logger.debug inspect if RubyLLM.config.log_stream_debug
31
+ RubyLLM.logger.debug { inspect } if RubyLLM.config.log_stream_debug
31
32
  end
32
33
 
33
34
  def to_message(response)
34
35
  Message.new(
35
36
  role: :assistant,
36
37
  content: content.empty? ? nil : content,
38
+ thinking: Thinking.build(
39
+ text: @thinking_text.empty? ? nil : @thinking_text,
40
+ signature: @thinking_signature
41
+ ),
42
+ tokens: Tokens.build(
43
+ input: @input_tokens,
44
+ output: @output_tokens,
45
+ cached: @cached_tokens,
46
+ cache_creation: @cache_creation_tokens,
47
+ thinking: @thinking_tokens
48
+ ),
37
49
  model_id: model_id,
38
50
  conversation_id: conversation_id,
39
51
  tool_calls: tool_calls_from_stream,
40
- input_tokens: @input_tokens,
41
- output_tokens: @output_tokens,
42
- cached_tokens: @cached_tokens,
43
- cache_creation_tokens: @cache_creation_tokens,
44
52
  raw: response
45
53
  )
46
54
  end
@@ -60,26 +68,38 @@ module RubyLLM
60
68
  ToolCall.new(
61
69
  id: tc.id,
62
70
  name: tc.name,
63
- arguments: arguments
71
+ arguments: arguments,
72
+ thought_signature: tc.thought_signature
64
73
  )
65
74
  end
66
75
  end
67
76
 
68
- def accumulate_tool_calls(new_tool_calls)
69
- RubyLLM.logger.debug "Accumulating tool calls: #{new_tool_calls}" if RubyLLM.config.log_stream_debug
77
+ def accumulate_tool_calls(new_tool_calls) # rubocop:disable Metrics/PerceivedComplexity
78
+ RubyLLM.logger.debug { "Accumulating tool calls: #{new_tool_calls}" } if RubyLLM.config.log_stream_debug
70
79
  new_tool_calls.each_value do |tool_call|
71
80
  if tool_call.id
72
81
  tool_call_id = tool_call.id.empty? ? SecureRandom.uuid : tool_call.id
73
- tool_call_arguments = tool_call.arguments.empty? ? +'' : tool_call.arguments
82
+ tool_call_arguments = tool_call.arguments
83
+ if tool_call_arguments.nil? || (tool_call_arguments.respond_to?(:empty?) && tool_call_arguments.empty?)
84
+ tool_call_arguments = +''
85
+ end
74
86
  @tool_calls[tool_call.id] = ToolCall.new(
75
87
  id: tool_call_id,
76
88
  name: tool_call.name,
77
- arguments: tool_call_arguments
89
+ arguments: tool_call_arguments,
90
+ thought_signature: tool_call.thought_signature
78
91
  )
79
92
  @latest_tool_call_id = tool_call.id
80
93
  else
81
94
  existing = @tool_calls[@latest_tool_call_id]
82
- existing.arguments << tool_call.arguments if existing
95
+ if existing
96
+ fragment = tool_call.arguments
97
+ fragment = '' if fragment.nil?
98
+ existing.arguments << fragment
99
+ if tool_call.thought_signature && existing.thought_signature.nil?
100
+ existing.thought_signature = tool_call.thought_signature
101
+ end
102
+ end
83
103
  end
84
104
  end
85
105
  end
@@ -98,6 +118,88 @@ module RubyLLM
98
118
  @output_tokens = chunk.output_tokens if chunk.output_tokens
99
119
  @cached_tokens = chunk.cached_tokens if chunk.cached_tokens
100
120
  @cache_creation_tokens = chunk.cache_creation_tokens if chunk.cache_creation_tokens
121
+ @thinking_tokens = chunk.thinking_tokens if chunk.thinking_tokens
122
+ end
123
+
124
+ def handle_chunk_content(chunk)
125
+ return accumulate_tool_calls(chunk.tool_calls) if chunk.tool_call?
126
+
127
+ content_text = chunk.content || ''
128
+ if content_text.is_a?(String)
129
+ append_text_with_thinking(content_text)
130
+ else
131
+ @content << content_text.to_s
132
+ end
133
+ end
134
+
135
+ def append_text_with_thinking(text)
136
+ content_chunk, thinking_chunk = extract_think_tags(text)
137
+ @content << content_chunk
138
+ @thinking_text << thinking_chunk if thinking_chunk
139
+ end
140
+
141
+ def append_thinking_from_chunk(chunk)
142
+ thinking = chunk.thinking
143
+ return unless thinking
144
+
145
+ @thinking_text << thinking.text.to_s if thinking.text
146
+ @thinking_signature ||= thinking.signature # rubocop:disable Naming/MemoizedInstanceVariableName
147
+ end
148
+
149
+ def extract_think_tags(text)
150
+ start_tag = '<think>'
151
+ end_tag = '</think>'
152
+ remaining = @pending_think_tag + text
153
+ @pending_think_tag = +''
154
+
155
+ output = +''
156
+ thinking = +''
157
+
158
+ until remaining.empty?
159
+ remaining = if @inside_think_tag
160
+ consume_think_content(remaining, end_tag, thinking)
161
+ else
162
+ consume_non_think_content(remaining, start_tag, output)
163
+ end
164
+ end
165
+
166
+ [output, thinking.empty? ? nil : thinking]
167
+ end
168
+
169
+ def consume_think_content(remaining, end_tag, thinking)
170
+ end_index = remaining.index(end_tag)
171
+ if end_index
172
+ thinking << remaining.slice(0, end_index)
173
+ @inside_think_tag = false
174
+ remaining.slice((end_index + end_tag.length)..) || +''
175
+ else
176
+ suffix_len = longest_suffix_prefix(remaining, end_tag)
177
+ thinking << remaining.slice(0, remaining.length - suffix_len)
178
+ @pending_think_tag = remaining.slice(-suffix_len, suffix_len)
179
+ +''
180
+ end
181
+ end
182
+
183
+ def consume_non_think_content(remaining, start_tag, output)
184
+ start_index = remaining.index(start_tag)
185
+ if start_index
186
+ output << remaining.slice(0, start_index)
187
+ @inside_think_tag = true
188
+ remaining.slice((start_index + start_tag.length)..) || +''
189
+ else
190
+ suffix_len = longest_suffix_prefix(remaining, start_tag)
191
+ output << remaining.slice(0, remaining.length - suffix_len)
192
+ @pending_think_tag = remaining.slice(-suffix_len, suffix_len)
193
+ +''
194
+ end
195
+ end
196
+
197
+ def longest_suffix_prefix(text, tag)
198
+ max = [text.length, tag.length - 1].min
199
+ max.downto(1) do |len|
200
+ return len if text.end_with?(tag[0, len])
201
+ end
202
+ 0
101
203
  end
102
204
  end
103
205
  end
@@ -24,13 +24,13 @@ module RubyLLM
24
24
  end
25
25
 
26
26
  message = accumulator.to_message(response)
27
- RubyLLM.logger.debug "Stream completed: #{message.content}"
27
+ RubyLLM.logger.debug { "Stream completed: #{message.content}" }
28
28
  message
29
29
  end
30
30
 
31
31
  def handle_stream(&block)
32
- to_json_stream do |data|
33
- block.call(build_chunk(data)) if data
32
+ build_on_data_handler do |data|
33
+ block.call(build_chunk(data)) if data.is_a?(Hash)
34
34
  end
35
35
  end
36
36
 
@@ -40,23 +40,19 @@ module RubyLLM
40
40
  Faraday::VERSION.start_with?('1')
41
41
  end
42
42
 
43
- def to_json_stream(&)
43
+ def build_on_data_handler(&handler)
44
44
  buffer = +''
45
45
  parser = EventStreamParser::Parser.new
46
46
 
47
- create_stream_processor(parser, buffer, &)
48
- end
49
-
50
- def create_stream_processor(parser, buffer, &)
51
- if faraday_1?
52
- legacy_stream_processor(parser, &)
53
- else
54
- stream_processor(parser, buffer, &)
55
- end
47
+ FaradayHandlers.build(
48
+ faraday_v1: faraday_1?,
49
+ on_chunk: ->(chunk, env) { process_stream_chunk(chunk, parser, env, &handler) },
50
+ on_failed_response: ->(chunk, env) { handle_failed_response(chunk, buffer, env) }
51
+ )
56
52
  end
57
53
 
58
54
  def process_stream_chunk(chunk, parser, env, &)
59
- RubyLLM.logger.debug "Received chunk: #{chunk}" if RubyLLM.config.log_stream_debug
55
+ RubyLLM.logger.debug { "Received chunk: #{chunk}" } if RubyLLM.config.log_stream_debug
60
56
 
61
57
  if error_chunk?(chunk)
62
58
  handle_error_chunk(chunk, env)
@@ -67,22 +63,6 @@ module RubyLLM
67
63
  end
68
64
  end
69
65
 
70
- def legacy_stream_processor(parser, &block)
71
- proc do |chunk, _size|
72
- process_stream_chunk(chunk, parser, nil, &block)
73
- end
74
- end
75
-
76
- def stream_processor(parser, buffer, &block)
77
- proc do |chunk, _bytes, env|
78
- if env&.status == 200
79
- process_stream_chunk(chunk, parser, env, &block)
80
- else
81
- handle_failed_response(chunk, buffer, env)
82
- end
83
- end
84
- end
85
-
86
66
  def error_chunk?(chunk)
87
67
  chunk.start_with?('event: error')
88
68
  end
@@ -92,32 +72,20 @@ module RubyLLM
92
72
  end
93
73
 
94
74
  def handle_json_error_chunk(chunk, env)
95
- parsed_data = JSON.parse(chunk)
96
- status, _message = parse_streaming_error(parsed_data.to_json)
97
- error_response = build_stream_error_response(parsed_data, env, status)
98
- ErrorMiddleware.parse_error(provider: self, response: error_response)
99
- rescue JSON::ParserError => e
100
- RubyLLM.logger.debug "Failed to parse JSON error chunk: #{e.message}"
75
+ parse_error_from_json(chunk, env, 'Failed to parse JSON error chunk')
101
76
  end
102
77
 
103
78
  def handle_error_chunk(chunk, env)
104
79
  error_data = chunk.split("\n")[1].delete_prefix('data: ')
105
- parsed_data = JSON.parse(error_data)
106
- status, _message = parse_streaming_error(parsed_data.to_json)
107
- error_response = build_stream_error_response(parsed_data, env, status)
108
- ErrorMiddleware.parse_error(provider: self, response: error_response)
109
- rescue JSON::ParserError => e
110
- RubyLLM.logger.debug "Failed to parse error chunk: #{e.message}"
80
+ parse_error_from_json(error_data, env, 'Failed to parse error chunk')
111
81
  end
112
82
 
113
83
  def handle_failed_response(chunk, buffer, env)
114
84
  buffer << chunk
115
85
  error_data = JSON.parse(buffer)
116
- status, _message = parse_streaming_error(error_data.to_json)
117
- error_response = env.merge(body: error_data, status: status || env.status)
118
- ErrorMiddleware.parse_error(provider: self, response: error_response)
86
+ handle_parsed_error(error_data, env)
119
87
  rescue JSON::ParserError
120
- RubyLLM.logger.debug "Accumulating error chunk: #{chunk}"
88
+ RubyLLM.logger.debug { "Accumulating error chunk: #{chunk}" }
121
89
  end
122
90
 
123
91
  def handle_sse(chunk, parser, env, &block)
@@ -135,30 +103,36 @@ module RubyLLM
135
103
  parsed = JSON.parse(data)
136
104
  return parsed unless parsed.is_a?(Hash) && parsed.key?('error')
137
105
 
138
- status, _message = parse_streaming_error(parsed.to_json)
139
- error_response = build_stream_error_response(parsed, env, status)
140
- ErrorMiddleware.parse_error(provider: self, response: error_response)
106
+ handle_parsed_error(parsed, env)
141
107
  rescue JSON::ParserError => e
142
- RubyLLM.logger.debug "Failed to parse data chunk: #{e.message}"
108
+ RubyLLM.logger.debug { "Failed to parse data chunk: #{e.message}" }
143
109
  end
144
110
 
145
111
  def handle_error_event(data, env)
146
- parsed_data = JSON.parse(data)
147
- status, _message = parse_streaming_error(parsed_data.to_json)
148
- error_response = build_stream_error_response(parsed_data, env, status)
149
- ErrorMiddleware.parse_error(provider: self, response: error_response)
150
- rescue JSON::ParserError => e
151
- RubyLLM.logger.debug "Failed to parse error event: #{e.message}"
112
+ parse_error_from_json(data, env, 'Failed to parse error event')
152
113
  end
153
114
 
154
115
  def parse_streaming_error(data)
155
116
  error_data = JSON.parse(data)
156
117
  [500, error_data['message'] || 'Unknown streaming error']
157
118
  rescue JSON::ParserError => e
158
- RubyLLM.logger.debug "Failed to parse streaming error: #{e.message}"
119
+ RubyLLM.logger.debug { "Failed to parse streaming error: #{e.message}" }
159
120
  [500, "Failed to parse error: #{data}"]
160
121
  end
161
122
 
123
+ def handle_parsed_error(parsed_data, env)
124
+ status, _message = parse_streaming_error(parsed_data.to_json)
125
+ error_response = build_stream_error_response(parsed_data, env, status)
126
+ ErrorMiddleware.parse_error(provider: self, response: error_response)
127
+ end
128
+
129
+ def parse_error_from_json(data, env, error_message)
130
+ parsed_data = JSON.parse(data)
131
+ handle_parsed_error(parsed_data, env)
132
+ rescue JSON::ParserError => e
133
+ RubyLLM.logger.debug { "#{error_message}: #{e.message}" }
134
+ end
135
+
162
136
  def build_stream_error_response(parsed_data, env, status)
163
137
  error_status = status || env&.status || 500
164
138
 
@@ -168,5 +142,34 @@ module RubyLLM
168
142
  env.merge(body: parsed_data, status: error_status)
169
143
  end
170
144
  end
145
+
146
+ # Builds Faraday on_data handlers for different major versions.
147
+ module FaradayHandlers
148
+ module_function
149
+
150
+ def build(faraday_v1:, on_chunk:, on_failed_response:)
151
+ if faraday_v1
152
+ v1_on_data(on_chunk)
153
+ else
154
+ v2_on_data(on_chunk, on_failed_response)
155
+ end
156
+ end
157
+
158
+ def v1_on_data(on_chunk)
159
+ proc do |chunk, _size|
160
+ on_chunk.call(chunk, nil)
161
+ end
162
+ end
163
+
164
+ def v2_on_data(on_chunk, on_failed_response)
165
+ proc do |chunk, _bytes, env|
166
+ if env&.status == 200
167
+ on_chunk.call(chunk, env)
168
+ else
169
+ on_failed_response.call(chunk, env)
170
+ end
171
+ end
172
+ end
173
+ end
171
174
  end
172
175
  end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ # Represents provider thinking output.
5
+ class Thinking
6
+ attr_reader :text, :signature
7
+
8
+ def initialize(text: nil, signature: nil)
9
+ @text = text
10
+ @signature = signature
11
+ end
12
+
13
+ def self.build(text: nil, signature: nil)
14
+ text = nil if text.is_a?(String) && text.empty?
15
+ signature = nil if signature.is_a?(String) && signature.empty?
16
+
17
+ return nil if text.nil? && signature.nil?
18
+
19
+ new(text: text, signature: signature)
20
+ end
21
+
22
+ def pretty_print(printer)
23
+ printer.object_group(self) do
24
+ printer.breakable
25
+ printer.text 'text='
26
+ printer.pp text
27
+ printer.comma_breakable
28
+ printer.text 'signature='
29
+ printer.pp(signature ? '[REDACTED]' : nil)
30
+ end
31
+ end
32
+ end
33
+
34
+ class Thinking
35
+ # Normalized config for thinking across providers.
36
+ class Config
37
+ attr_reader :effort, :budget
38
+
39
+ def initialize(effort: nil, budget: nil)
40
+ @effort = effort.is_a?(Symbol) ? effort.to_s : effort
41
+ @budget = budget
42
+ end
43
+
44
+ def enabled?
45
+ !effort.nil? || !budget.nil?
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ # Represents token usage for a response.
5
+ class Tokens
6
+ attr_reader :input, :output, :cached, :cache_creation, :thinking
7
+
8
+ # rubocop:disable Metrics/ParameterLists
9
+ def initialize(input: nil, output: nil, cached: nil, cache_creation: nil, thinking: nil, reasoning: nil)
10
+ @input = input
11
+ @output = output
12
+ @cached = cached
13
+ @cache_creation = cache_creation
14
+ @thinking = thinking || reasoning
15
+ end
16
+ # rubocop:enable Metrics/ParameterLists
17
+
18
+ # rubocop:disable Metrics/ParameterLists
19
+ def self.build(input: nil, output: nil, cached: nil, cache_creation: nil, thinking: nil, reasoning: nil)
20
+ return nil if [input, output, cached, cache_creation, thinking, reasoning].all?(&:nil?)
21
+
22
+ new(
23
+ input: input,
24
+ output: output,
25
+ cached: cached,
26
+ cache_creation: cache_creation,
27
+ thinking: thinking,
28
+ reasoning: reasoning
29
+ )
30
+ end
31
+ # rubocop:enable Metrics/ParameterLists
32
+
33
+ def to_h
34
+ {
35
+ input_tokens: input,
36
+ output_tokens: output,
37
+ cached_tokens: cached,
38
+ cache_creation_tokens: cache_creation,
39
+ thinking_tokens: thinking
40
+ }.compact
41
+ end
42
+
43
+ def reasoning
44
+ thinking
45
+ end
46
+ end
47
+ end