dify_llm 1.9.1 → 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 (170) 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/templates/migration.rb.tt +1 -1
  67. data/lib/generators/ruby_llm/upgrade_to_v1_7/upgrade_to_v1_7_generator.rb +2 -4
  68. data/lib/generators/ruby_llm/upgrade_to_v1_9/upgrade_to_v1_9_generator.rb +1 -1
  69. data/lib/ruby_llm/active_record/acts_as.rb +10 -4
  70. data/lib/ruby_llm/active_record/acts_as_legacy.rb +132 -27
  71. data/lib/ruby_llm/active_record/chat_methods.rb +132 -28
  72. data/lib/ruby_llm/active_record/message_methods.rb +58 -8
  73. data/lib/ruby_llm/active_record/model_methods.rb +1 -1
  74. data/lib/ruby_llm/active_record/payload_helpers.rb +26 -0
  75. data/lib/ruby_llm/active_record/tool_call_methods.rb +15 -0
  76. data/lib/ruby_llm/agent.rb +365 -0
  77. data/lib/ruby_llm/aliases.json +199 -62
  78. data/lib/ruby_llm/attachment.rb +15 -4
  79. data/lib/ruby_llm/chat.rb +150 -22
  80. data/lib/ruby_llm/configuration.rb +65 -65
  81. data/lib/ruby_llm/connection.rb +11 -7
  82. data/lib/ruby_llm/content.rb +6 -2
  83. data/lib/ruby_llm/error.rb +37 -1
  84. data/lib/ruby_llm/message.rb +43 -15
  85. data/lib/ruby_llm/model/info.rb +15 -13
  86. data/lib/ruby_llm/models.json +37560 -14094
  87. data/lib/ruby_llm/models.rb +321 -38
  88. data/lib/ruby_llm/models_schema.json +2 -2
  89. data/lib/ruby_llm/provider.rb +26 -4
  90. data/lib/ruby_llm/providers/anthropic/capabilities.rb +5 -119
  91. data/lib/ruby_llm/providers/anthropic/chat.rb +149 -17
  92. data/lib/ruby_llm/providers/anthropic/media.rb +2 -2
  93. data/lib/ruby_llm/providers/anthropic/models.rb +3 -9
  94. data/lib/ruby_llm/providers/anthropic/streaming.rb +25 -1
  95. data/lib/ruby_llm/providers/anthropic/tools.rb +20 -0
  96. data/lib/ruby_llm/providers/anthropic.rb +5 -1
  97. data/lib/ruby_llm/providers/azure/chat.rb +29 -0
  98. data/lib/ruby_llm/providers/azure/embeddings.rb +24 -0
  99. data/lib/ruby_llm/providers/azure/media.rb +45 -0
  100. data/lib/ruby_llm/providers/azure/models.rb +14 -0
  101. data/lib/ruby_llm/providers/azure.rb +148 -0
  102. data/lib/ruby_llm/providers/bedrock/auth.rb +122 -0
  103. data/lib/ruby_llm/providers/bedrock/chat.rb +357 -28
  104. data/lib/ruby_llm/providers/bedrock/media.rb +62 -33
  105. data/lib/ruby_llm/providers/bedrock/models.rb +107 -62
  106. data/lib/ruby_llm/providers/bedrock/streaming.rb +309 -8
  107. data/lib/ruby_llm/providers/bedrock.rb +69 -52
  108. data/lib/ruby_llm/providers/deepseek/capabilities.rb +4 -114
  109. data/lib/ruby_llm/providers/deepseek.rb +5 -1
  110. data/lib/ruby_llm/providers/dify/chat.rb +82 -7
  111. data/lib/ruby_llm/providers/dify/media.rb +2 -2
  112. data/lib/ruby_llm/providers/dify/streaming.rb +26 -4
  113. data/lib/ruby_llm/providers/dify.rb +4 -0
  114. data/lib/ruby_llm/providers/gemini/capabilities.rb +45 -207
  115. data/lib/ruby_llm/providers/gemini/chat.rb +88 -6
  116. data/lib/ruby_llm/providers/gemini/images.rb +1 -1
  117. data/lib/ruby_llm/providers/gemini/models.rb +2 -4
  118. data/lib/ruby_llm/providers/gemini/streaming.rb +34 -2
  119. data/lib/ruby_llm/providers/gemini/tools.rb +35 -3
  120. data/lib/ruby_llm/providers/gemini.rb +4 -0
  121. data/lib/ruby_llm/providers/gpustack/capabilities.rb +20 -0
  122. data/lib/ruby_llm/providers/gpustack/chat.rb +1 -1
  123. data/lib/ruby_llm/providers/gpustack.rb +8 -0
  124. data/lib/ruby_llm/providers/mistral/capabilities.rb +8 -0
  125. data/lib/ruby_llm/providers/mistral/chat.rb +59 -1
  126. data/lib/ruby_llm/providers/mistral.rb +4 -0
  127. data/lib/ruby_llm/providers/ollama/capabilities.rb +20 -0
  128. data/lib/ruby_llm/providers/ollama/chat.rb +1 -1
  129. data/lib/ruby_llm/providers/ollama.rb +11 -1
  130. data/lib/ruby_llm/providers/openai/capabilities.rb +96 -192
  131. data/lib/ruby_llm/providers/openai/chat.rb +101 -7
  132. data/lib/ruby_llm/providers/openai/media.rb +5 -2
  133. data/lib/ruby_llm/providers/openai/models.rb +2 -4
  134. data/lib/ruby_llm/providers/openai/streaming.rb +11 -3
  135. data/lib/ruby_llm/providers/openai/temperature.rb +28 -0
  136. data/lib/ruby_llm/providers/openai/tools.rb +27 -2
  137. data/lib/ruby_llm/providers/openai.rb +11 -1
  138. data/lib/ruby_llm/providers/openrouter/chat.rb +168 -0
  139. data/lib/ruby_llm/providers/openrouter/images.rb +69 -0
  140. data/lib/ruby_llm/providers/openrouter/streaming.rb +74 -0
  141. data/lib/ruby_llm/providers/openrouter.rb +37 -1
  142. data/lib/ruby_llm/providers/perplexity/capabilities.rb +34 -99
  143. data/lib/ruby_llm/providers/perplexity/models.rb +12 -14
  144. data/lib/ruby_llm/providers/perplexity.rb +4 -0
  145. data/lib/ruby_llm/providers/vertexai/models.rb +1 -1
  146. data/lib/ruby_llm/providers/vertexai.rb +23 -7
  147. data/lib/ruby_llm/providers/xai/chat.rb +15 -0
  148. data/lib/ruby_llm/providers/xai/models.rb +75 -0
  149. data/lib/ruby_llm/providers/xai.rb +32 -0
  150. data/lib/ruby_llm/stream_accumulator.rb +120 -18
  151. data/lib/ruby_llm/streaming.rb +82 -60
  152. data/lib/ruby_llm/thinking.rb +49 -0
  153. data/lib/ruby_llm/tokens.rb +47 -0
  154. data/lib/ruby_llm/tool.rb +49 -4
  155. data/lib/ruby_llm/tool_call.rb +6 -3
  156. data/lib/ruby_llm/version.rb +1 -1
  157. data/lib/ruby_llm.rb +14 -8
  158. data/lib/tasks/models.rake +62 -23
  159. data/lib/tasks/release.rake +1 -1
  160. data/lib/tasks/ruby_llm.rake +9 -1
  161. data/lib/tasks/vcr.rake +33 -1
  162. metadata +67 -16
  163. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_message.html.erb.tt +0 -13
  164. data/lib/ruby_llm/providers/bedrock/capabilities.rb +0 -167
  165. data/lib/ruby_llm/providers/bedrock/signing.rb +0 -831
  166. data/lib/ruby_llm/providers/bedrock/streaming/base.rb +0 -51
  167. data/lib/ruby_llm/providers/bedrock/streaming/content_extraction.rb +0 -71
  168. data/lib/ruby_llm/providers/bedrock/streaming/message_processing.rb +0 -67
  169. data/lib/ruby_llm/providers/bedrock/streaming/payload_processing.rb +0 -80
  170. 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,74 +40,52 @@ 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)
59
+ elsif json_error_payload?(chunk)
60
+ handle_json_error_chunk(chunk, env)
63
61
  else
64
62
  yield handle_sse(chunk, parser, env, &)
65
63
  end
66
64
  end
67
65
 
68
- def legacy_stream_processor(parser, &block)
69
- proc do |chunk, _size|
70
- process_stream_chunk(chunk, parser, nil, &block)
71
- end
66
+ def error_chunk?(chunk)
67
+ chunk.start_with?('event: error')
72
68
  end
73
69
 
74
- def stream_processor(parser, buffer, &block)
75
- proc do |chunk, _bytes, env|
76
- if env&.status == 200
77
- process_stream_chunk(chunk, parser, env, &block)
78
- else
79
- handle_failed_response(chunk, buffer, env)
80
- end
81
- end
70
+ def json_error_payload?(chunk)
71
+ chunk.lstrip.start_with?('{') && chunk.include?('"error"')
82
72
  end
83
73
 
84
- def error_chunk?(chunk)
85
- chunk.start_with?('event: error')
74
+ def handle_json_error_chunk(chunk, env)
75
+ parse_error_from_json(chunk, env, 'Failed to parse JSON error chunk')
86
76
  end
87
77
 
88
78
  def handle_error_chunk(chunk, env)
89
79
  error_data = chunk.split("\n")[1].delete_prefix('data: ')
90
- status, _message = parse_streaming_error(error_data)
91
- parsed_data = JSON.parse(error_data)
92
-
93
- error_response = if faraday_1?
94
- Struct.new(:body, :status).new(parsed_data, status)
95
- else
96
- env.merge(body: parsed_data, status: status)
97
- end
98
-
99
- ErrorMiddleware.parse_error(provider: self, response: error_response)
100
- rescue JSON::ParserError => e
101
- RubyLLM.logger.debug "Failed to parse error chunk: #{e.message}"
80
+ parse_error_from_json(error_data, env, 'Failed to parse error chunk')
102
81
  end
103
82
 
104
83
  def handle_failed_response(chunk, buffer, env)
105
84
  buffer << chunk
106
85
  error_data = JSON.parse(buffer)
107
- error_response = env.merge(body: error_data)
108
- ErrorMiddleware.parse_error(provider: self, response: error_response)
86
+ handle_parsed_error(error_data, env)
109
87
  rescue JSON::ParserError
110
- RubyLLM.logger.debug "Accumulating error chunk: #{chunk}"
88
+ RubyLLM.logger.debug { "Accumulating error chunk: #{chunk}" }
111
89
  end
112
90
 
113
91
  def handle_sse(chunk, parser, env, &block)
@@ -116,38 +94,82 @@ module RubyLLM
116
94
  when :error
117
95
  handle_error_event(data, env)
118
96
  else
119
- yield handle_data(data, &block) unless data == '[DONE]'
97
+ yield handle_data(data, env, &block) unless data == '[DONE]'
120
98
  end
121
99
  end
122
100
  end
123
101
 
124
- def handle_data(data)
125
- JSON.parse(data)
102
+ def handle_data(data, env)
103
+ parsed = JSON.parse(data)
104
+ return parsed unless parsed.is_a?(Hash) && parsed.key?('error')
105
+
106
+ handle_parsed_error(parsed, env)
126
107
  rescue JSON::ParserError => e
127
- RubyLLM.logger.debug "Failed to parse data chunk: #{e.message}"
108
+ RubyLLM.logger.debug { "Failed to parse data chunk: #{e.message}" }
128
109
  end
129
110
 
130
111
  def handle_error_event(data, env)
131
- status, _message = parse_streaming_error(data)
132
- parsed_data = JSON.parse(data)
133
-
134
- error_response = if faraday_1?
135
- Struct.new(:body, :status).new(parsed_data, status)
136
- else
137
- env.merge(body: parsed_data, status: status)
138
- end
139
-
140
- ErrorMiddleware.parse_error(provider: self, response: error_response)
141
- rescue JSON::ParserError => e
142
- RubyLLM.logger.debug "Failed to parse error event: #{e.message}"
112
+ parse_error_from_json(data, env, 'Failed to parse error event')
143
113
  end
144
114
 
145
115
  def parse_streaming_error(data)
146
116
  error_data = JSON.parse(data)
147
117
  [500, error_data['message'] || 'Unknown streaming error']
148
118
  rescue JSON::ParserError => e
149
- RubyLLM.logger.debug "Failed to parse streaming error: #{e.message}"
119
+ RubyLLM.logger.debug { "Failed to parse streaming error: #{e.message}" }
150
120
  [500, "Failed to parse error: #{data}"]
151
121
  end
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
+
136
+ def build_stream_error_response(parsed_data, env, status)
137
+ error_status = status || env&.status || 500
138
+
139
+ if faraday_1?
140
+ Struct.new(:body, :status).new(parsed_data, error_status)
141
+ else
142
+ env.merge(body: parsed_data, status: error_status)
143
+ end
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
152
174
  end
153
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