ruby_llm_community 0.0.6 → 1.1.0

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 (134) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +20 -3
  3. data/lib/generators/ruby_llm/chat_ui/chat_ui_generator.rb +127 -0
  4. data/lib/generators/ruby_llm/chat_ui/templates/controllers/chats_controller.rb.tt +39 -0
  5. data/lib/generators/ruby_llm/chat_ui/templates/controllers/messages_controller.rb.tt +24 -0
  6. data/lib/generators/ruby_llm/chat_ui/templates/controllers/models_controller.rb.tt +14 -0
  7. data/lib/generators/ruby_llm/chat_ui/templates/jobs/chat_response_job.rb.tt +12 -0
  8. data/lib/generators/ruby_llm/chat_ui/templates/views/chats/_chat.html.erb.tt +16 -0
  9. data/lib/generators/ruby_llm/chat_ui/templates/views/chats/_form.html.erb.tt +29 -0
  10. data/lib/generators/ruby_llm/chat_ui/templates/views/chats/index.html.erb.tt +16 -0
  11. data/lib/generators/ruby_llm/chat_ui/templates/views/chats/new.html.erb.tt +11 -0
  12. data/lib/generators/ruby_llm/chat_ui/templates/views/chats/show.html.erb.tt +23 -0
  13. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_form.html.erb.tt +21 -0
  14. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_message.html.erb.tt +10 -0
  15. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/create.turbo_stream.erb.tt +9 -0
  16. data/lib/generators/ruby_llm/chat_ui/templates/views/models/_model.html.erb.tt +16 -0
  17. data/lib/generators/ruby_llm/chat_ui/templates/views/models/index.html.erb.tt +30 -0
  18. data/lib/generators/ruby_llm/chat_ui/templates/views/models/show.html.erb.tt +18 -0
  19. data/lib/generators/ruby_llm/install/install_generator.rb +227 -0
  20. data/lib/generators/ruby_llm/install/templates/chat_model.rb.tt +2 -2
  21. data/lib/generators/ruby_llm/install/templates/create_chats_migration.rb.tt +4 -4
  22. data/lib/generators/ruby_llm/install/templates/create_messages_migration.rb.tt +8 -7
  23. data/lib/generators/ruby_llm/install/templates/create_models_migration.rb.tt +43 -0
  24. data/lib/generators/ruby_llm/install/templates/create_tool_calls_migration.rb.tt +6 -5
  25. data/lib/generators/ruby_llm/install/templates/initializer.rb.tt +10 -4
  26. data/lib/generators/ruby_llm/install/templates/message_model.rb.tt +4 -3
  27. data/lib/generators/ruby_llm/install/templates/model_model.rb.tt +3 -0
  28. data/lib/generators/ruby_llm/install/templates/tool_call_model.rb.tt +2 -2
  29. data/lib/generators/ruby_llm/upgrade_to_v1_7/templates/migration.rb.tt +137 -0
  30. data/lib/generators/ruby_llm/upgrade_to_v1_7/upgrade_to_v1_7_generator.rb +170 -0
  31. data/lib/ruby_llm/active_record/acts_as.rb +112 -332
  32. data/lib/ruby_llm/active_record/acts_as_legacy.rb +403 -0
  33. data/lib/ruby_llm/active_record/chat_methods.rb +336 -0
  34. data/lib/ruby_llm/active_record/message_methods.rb +72 -0
  35. data/lib/ruby_llm/active_record/model_methods.rb +84 -0
  36. data/lib/ruby_llm/aliases.json +130 -11
  37. data/lib/ruby_llm/aliases.rb +7 -25
  38. data/lib/ruby_llm/attachment.rb +22 -0
  39. data/lib/ruby_llm/chat.rb +10 -17
  40. data/lib/ruby_llm/configuration.rb +11 -12
  41. data/lib/ruby_llm/connection.rb +4 -4
  42. data/lib/ruby_llm/connection_multipart.rb +19 -0
  43. data/lib/ruby_llm/content.rb +5 -2
  44. data/lib/ruby_llm/embedding.rb +1 -2
  45. data/lib/ruby_llm/error.rb +0 -8
  46. data/lib/ruby_llm/image.rb +23 -8
  47. data/lib/ruby_llm/image_attachment.rb +30 -0
  48. data/lib/ruby_llm/message.rb +7 -7
  49. data/lib/ruby_llm/model/info.rb +12 -10
  50. data/lib/ruby_llm/model/pricing.rb +0 -3
  51. data/lib/ruby_llm/model/pricing_category.rb +0 -2
  52. data/lib/ruby_llm/model/pricing_tier.rb +0 -1
  53. data/lib/ruby_llm/models.json +4705 -2144
  54. data/lib/ruby_llm/models.rb +56 -35
  55. data/lib/ruby_llm/provider.rb +14 -12
  56. data/lib/ruby_llm/providers/anthropic/capabilities.rb +1 -46
  57. data/lib/ruby_llm/providers/anthropic/chat.rb +2 -2
  58. data/lib/ruby_llm/providers/anthropic/media.rb +1 -2
  59. data/lib/ruby_llm/providers/anthropic/tools.rb +1 -2
  60. data/lib/ruby_llm/providers/anthropic.rb +1 -2
  61. data/lib/ruby_llm/providers/bedrock/chat.rb +2 -4
  62. data/lib/ruby_llm/providers/bedrock/media.rb +0 -1
  63. data/lib/ruby_llm/providers/bedrock/models.rb +19 -3
  64. data/lib/ruby_llm/providers/bedrock/streaming/base.rb +0 -12
  65. data/lib/ruby_llm/providers/bedrock/streaming/content_extraction.rb +0 -7
  66. data/lib/ruby_llm/providers/bedrock/streaming/message_processing.rb +0 -12
  67. data/lib/ruby_llm/providers/bedrock/streaming/payload_processing.rb +0 -12
  68. data/lib/ruby_llm/providers/bedrock/streaming/prelude_handling.rb +0 -13
  69. data/lib/ruby_llm/providers/bedrock/streaming.rb +0 -18
  70. data/lib/ruby_llm/providers/bedrock.rb +1 -2
  71. data/lib/ruby_llm/providers/deepseek/capabilities.rb +1 -2
  72. data/lib/ruby_llm/providers/deepseek/chat.rb +0 -1
  73. data/lib/ruby_llm/providers/gemini/capabilities.rb +28 -100
  74. data/lib/ruby_llm/providers/gemini/chat.rb +57 -29
  75. data/lib/ruby_llm/providers/gemini/embeddings.rb +0 -2
  76. data/lib/ruby_llm/providers/gemini/images.rb +1 -2
  77. data/lib/ruby_llm/providers/gemini/media.rb +1 -2
  78. data/lib/ruby_llm/providers/gemini/models.rb +1 -2
  79. data/lib/ruby_llm/providers/gemini/streaming.rb +15 -1
  80. data/lib/ruby_llm/providers/gemini/tools.rb +0 -5
  81. data/lib/ruby_llm/providers/gpustack/chat.rb +11 -1
  82. data/lib/ruby_llm/providers/gpustack/media.rb +45 -0
  83. data/lib/ruby_llm/providers/gpustack/models.rb +44 -9
  84. data/lib/ruby_llm/providers/gpustack.rb +1 -0
  85. data/lib/ruby_llm/providers/mistral/capabilities.rb +2 -10
  86. data/lib/ruby_llm/providers/mistral/chat.rb +0 -2
  87. data/lib/ruby_llm/providers/mistral/embeddings.rb +0 -3
  88. data/lib/ruby_llm/providers/mistral/models.rb +0 -1
  89. data/lib/ruby_llm/providers/ollama/chat.rb +0 -1
  90. data/lib/ruby_llm/providers/ollama/media.rb +2 -7
  91. data/lib/ruby_llm/providers/ollama/models.rb +36 -0
  92. data/lib/ruby_llm/providers/ollama.rb +1 -0
  93. data/lib/ruby_llm/providers/openai/capabilities.rb +3 -16
  94. data/lib/ruby_llm/providers/openai/chat.rb +1 -3
  95. data/lib/ruby_llm/providers/openai/embeddings.rb +0 -3
  96. data/lib/ruby_llm/providers/openai/images.rb +73 -3
  97. data/lib/ruby_llm/providers/openai/media.rb +4 -5
  98. data/lib/ruby_llm/providers/openai/response.rb +121 -29
  99. data/lib/ruby_llm/providers/openai/response_media.rb +3 -3
  100. data/lib/ruby_llm/providers/openai/streaming.rb +110 -47
  101. data/lib/ruby_llm/providers/openai/tools.rb +12 -7
  102. data/lib/ruby_llm/providers/openai.rb +1 -3
  103. data/lib/ruby_llm/providers/openai_base.rb +2 -2
  104. data/lib/ruby_llm/providers/openrouter/models.rb +1 -16
  105. data/lib/ruby_llm/providers/perplexity/capabilities.rb +0 -1
  106. data/lib/ruby_llm/providers/perplexity/chat.rb +0 -1
  107. data/lib/ruby_llm/providers/perplexity.rb +1 -5
  108. data/lib/ruby_llm/providers/vertexai/chat.rb +14 -0
  109. data/lib/ruby_llm/providers/vertexai/embeddings.rb +32 -0
  110. data/lib/ruby_llm/providers/vertexai/models.rb +130 -0
  111. data/lib/ruby_llm/providers/vertexai/streaming.rb +14 -0
  112. data/lib/ruby_llm/providers/vertexai.rb +55 -0
  113. data/lib/ruby_llm/providers/xai/capabilities.rb +166 -0
  114. data/lib/ruby_llm/providers/xai/chat.rb +15 -0
  115. data/lib/ruby_llm/providers/xai/models.rb +48 -0
  116. data/lib/ruby_llm/providers/xai.rb +46 -0
  117. data/lib/ruby_llm/railtie.rb +20 -4
  118. data/lib/ruby_llm/stream_accumulator.rb +68 -10
  119. data/lib/ruby_llm/streaming.rb +16 -25
  120. data/lib/ruby_llm/tool.rb +2 -19
  121. data/lib/ruby_llm/tool_call.rb +0 -9
  122. data/lib/ruby_llm/utils.rb +5 -9
  123. data/lib/ruby_llm/version.rb +1 -1
  124. data/lib/ruby_llm_community.rb +8 -5
  125. data/lib/tasks/models.rake +549 -0
  126. data/lib/tasks/release.rake +37 -2
  127. data/lib/tasks/ruby_llm.rake +15 -0
  128. data/lib/tasks/vcr.rake +2 -9
  129. metadata +44 -6
  130. data/lib/generators/ruby_llm/install/templates/INSTALL_INFO.md.tt +0 -108
  131. data/lib/generators/ruby_llm/install_generator.rb +0 -121
  132. data/lib/tasks/aliases.rake +0 -235
  133. data/lib/tasks/models_docs.rake +0 -224
  134. data/lib/tasks/models_update.rake +0 -108
@@ -0,0 +1,130 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Providers
5
+ class VertexAI
6
+ # Models methods for the Vertex AI integration
7
+ module Models
8
+ # Gemini and other Google models that aren't returned by the API
9
+ KNOWN_GOOGLE_MODELS = %w[
10
+ gemini-2.5-flash-lite
11
+ gemini-2.5-pro
12
+ gemini-2.5-flash
13
+ gemini-2.0-flash-lite-001
14
+ gemini-2.0-flash-001
15
+ gemini-2.0-flash
16
+ gemini-2.0-flash-exp
17
+ gemini-1.5-pro-002
18
+ gemini-1.5-pro
19
+ gemini-1.5-flash-002
20
+ gemini-1.5-flash
21
+ gemini-1.5-flash-8b
22
+ gemini-pro
23
+ gemini-pro-vision
24
+ gemini-exp-1206
25
+ gemini-exp-1121
26
+ gemini-embedding-001
27
+ text-embedding-005
28
+ text-embedding-004
29
+ text-multilingual-embedding-002
30
+ ].freeze
31
+
32
+ def list_models
33
+ all_models = []
34
+ page_token = nil
35
+
36
+ all_models.concat(build_known_models)
37
+
38
+ loop do
39
+ response = @connection.get('publishers/google/models') do |req|
40
+ req.headers['x-goog-user-project'] = @config.vertexai_project_id
41
+ req.params = { pageSize: 100 }
42
+ req.params[:pageToken] = page_token if page_token
43
+ end
44
+
45
+ publisher_models = response.body['publisherModels'] || []
46
+ publisher_models.each do |model_data|
47
+ next if model_data['launchStage'] == 'DEPRECATED'
48
+
49
+ model_id = extract_model_id_from_path(model_data['name'])
50
+ all_models << build_model_from_api_data(model_data, model_id)
51
+ end
52
+
53
+ page_token = response.body['nextPageToken']
54
+ break unless page_token
55
+ end
56
+
57
+ all_models
58
+ rescue StandardError => e
59
+ RubyLLM.logger.debug "Error fetching Vertex AI models: #{e.message}"
60
+ build_known_models
61
+ end
62
+
63
+ private
64
+
65
+ def build_known_models
66
+ KNOWN_GOOGLE_MODELS.map do |model_id|
67
+ Model::Info.new(
68
+ id: model_id,
69
+ name: model_id,
70
+ provider: slug,
71
+ family: determine_model_family(model_id),
72
+ created_at: nil,
73
+ context_window: nil,
74
+ max_output_tokens: nil,
75
+ modalities: nil,
76
+ capabilities: %w[streaming function_calling],
77
+ pricing: nil,
78
+ metadata: {
79
+ source: 'known_models'
80
+ }
81
+ )
82
+ end
83
+ end
84
+
85
+ def build_model_from_api_data(model_data, model_id)
86
+ Model::Info.new(
87
+ id: model_id,
88
+ name: model_id,
89
+ provider: slug,
90
+ family: determine_model_family(model_id),
91
+ created_at: nil,
92
+ context_window: nil,
93
+ max_output_tokens: nil,
94
+ modalities: nil,
95
+ capabilities: extract_capabilities(model_data),
96
+ pricing: nil,
97
+ metadata: {
98
+ version_id: model_data['versionId'],
99
+ open_source_category: model_data['openSourceCategory'],
100
+ launch_stage: model_data['launchStage'],
101
+ supported_actions: model_data['supportedActions'],
102
+ publisher_model_template: model_data['publisherModelTemplate']
103
+ }
104
+ )
105
+ end
106
+
107
+ def extract_model_id_from_path(path)
108
+ path.split('/').last
109
+ end
110
+
111
+ def determine_model_family(model_id)
112
+ case model_id
113
+ when /^gemini-2\.\d+/ then 'gemini-2'
114
+ when /^gemini-1\.\d+/ then 'gemini-1.5'
115
+ when /^text-embedding/ then 'text-embedding'
116
+ when /bison/ then 'palm'
117
+ else 'gemini'
118
+ end
119
+ end
120
+
121
+ def extract_capabilities(model_data)
122
+ capabilities = ['streaming']
123
+ model_name = model_data['name']
124
+ capabilities << 'function_calling' if model_name.include?('gemini')
125
+ capabilities.uniq
126
+ end
127
+ end
128
+ end
129
+ end
130
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Providers
5
+ class VertexAI
6
+ # Streaming methods for the Vertex AI implementation
7
+ module Streaming
8
+ def stream_url
9
+ "projects/#{@config.vertexai_project_id}/locations/#{@config.vertexai_location}/publishers/google/models/#{@model}:streamGenerateContent?alt=sse" # rubocop:disable Layout/LineLength
10
+ end
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Providers
5
+ # Google Vertex AI implementation
6
+ class VertexAI < Gemini
7
+ include VertexAI::Chat
8
+ include VertexAI::Streaming
9
+ include VertexAI::Embeddings
10
+ include VertexAI::Models
11
+
12
+ def initialize(config)
13
+ super
14
+ @authorizer = nil
15
+ end
16
+
17
+ def api_base
18
+ "https://#{@config.vertexai_location}-aiplatform.googleapis.com/v1beta1"
19
+ end
20
+
21
+ def headers
22
+ {
23
+ 'Authorization' => "Bearer #{access_token}"
24
+ }
25
+ end
26
+
27
+ class << self
28
+ def configuration_requirements
29
+ %i[vertexai_project_id vertexai_location]
30
+ end
31
+ end
32
+
33
+ private
34
+
35
+ def access_token
36
+ return 'test-token' if defined?(VCR) && !VCR.current_cassette.recording?
37
+
38
+ initialize_authorizer unless @authorizer
39
+ @authorizer.fetch_access_token!['access_token']
40
+ end
41
+
42
+ def initialize_authorizer
43
+ require 'googleauth'
44
+ @authorizer = ::Google::Auth.get_application_default(
45
+ scope: [
46
+ 'https://www.googleapis.com/auth/cloud-platform',
47
+ 'https://www.googleapis.com/auth/generative-language.retriever'
48
+ ]
49
+ )
50
+ rescue LoadError
51
+ raise Error, 'The googleauth gem is required for Vertex AI. Please add it to your Gemfile: gem "googleauth"'
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,166 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Providers
5
+ class XAI
6
+ # Determines capabilities and pricing for xAI (Grok) models
7
+ # - https://docs.x.ai/docs/models
8
+ module Capabilities
9
+ module_function
10
+
11
+ # rubocop:disable Naming/VariableNumber
12
+ MODEL_PATTERNS = {
13
+ grok_2: /^grok-2(?!-vision)/,
14
+ grok_2_vision: /^grok-2-vision/,
15
+ grok_2_image: /^grok-2-image/,
16
+ grok_3: /^grok-3(?!-(?:fast|mini))/,
17
+ grok_3_fast: /^grok-3-fast/,
18
+ grok_3_mini: /^grok-3-mini(?!-fast)/,
19
+ grok_3_mini_fast: /^grok-3-mini-fast/,
20
+ grok_4: /^grok-4/
21
+ }.freeze
22
+ # rubocop:enable Naming/VariableNumber
23
+
24
+ def context_window_for(model_id)
25
+ case model_family(model_id)
26
+ when 'grok_4' then 256_000
27
+ when 'grok_2_vision' then 32_768
28
+ else 131_072
29
+ end
30
+ end
31
+
32
+ def max_tokens_for(_model_id)
33
+ 4_096
34
+ end
35
+
36
+ def supports_vision?(model_id)
37
+ case model_family(model_id)
38
+ when 'grok_2_vision' then true
39
+ else false
40
+ end
41
+ end
42
+
43
+ def supports_functions?(model_id)
44
+ model_family(model_id) != 'grok_2_image'
45
+ end
46
+
47
+ def supports_structured_output?(model_id)
48
+ model_family(model_id) != 'grok_2_image'
49
+ end
50
+
51
+ def supports_json_mode?(model_id)
52
+ supports_structured_output?(model_id)
53
+ end
54
+
55
+ # Pricing from API data (per million tokens)
56
+ # rubocop:disable Naming/VariableNumber
57
+ PRICES = {
58
+ grok_2: { input: 2.0, output: 10.0 },
59
+ grok_2_vision: { input: 2.0, output: 10.0 },
60
+ grok_3: { input: 3.0, output: 15.0, cached_input: 0.75 },
61
+ grok_3_fast: { input: 5.0, output: 25.0, cached_input: 1.25 },
62
+ grok_3_mini: { input: 0.3, output: 0.5, cached_input: 0.075 },
63
+ grok_3_mini_fast: { input: 0.6, output: 4.0, cached_input: 0.15 },
64
+ grok_4: { input: 3.0, output: 15.0, cached_input: 0.75 }
65
+ }.freeze
66
+ # rubocop:enable Naming/VariableNumber
67
+
68
+ def model_family(model_id)
69
+ MODEL_PATTERNS.each do |family, pattern|
70
+ return family.to_s if model_id.match?(pattern)
71
+ end
72
+ 'other'
73
+ end
74
+
75
+ def input_price_for(model_id)
76
+ family = model_family(model_id).to_sym
77
+ prices = PRICES.fetch(family, { input: default_input_price })
78
+ prices[:input] || default_input_price
79
+ end
80
+
81
+ def cached_input_price_for(model_id)
82
+ family = model_family(model_id).to_sym
83
+ prices = PRICES.fetch(family, {})
84
+ prices[:cached_input]
85
+ end
86
+
87
+ def output_price_for(model_id)
88
+ family = model_family(model_id).to_sym
89
+ prices = PRICES.fetch(family, { output: default_output_price })
90
+ prices[:output] || default_output_price
91
+ end
92
+
93
+ def model_type(model_id)
94
+ return 'image' if model_family(model_id) == 'grok_2_image'
95
+
96
+ 'chat'
97
+ end
98
+
99
+ def default_input_price
100
+ 2.0
101
+ end
102
+
103
+ def default_output_price
104
+ 10.0
105
+ end
106
+
107
+ def format_display_name(model_id)
108
+ model_id.then { |id| humanize(id) }
109
+ .then { |name| apply_special_formatting(name) }
110
+ end
111
+
112
+ def humanize(id)
113
+ id.tr('-', ' ')
114
+ .split
115
+ .map(&:capitalize)
116
+ .join(' ')
117
+ end
118
+
119
+ def apply_special_formatting(name)
120
+ name
121
+ .gsub(/^Grok /, 'Grok-')
122
+ .gsub(/(\d{4}) (\d{2}) (\d{2})/, '\1-\2-\3')
123
+ end
124
+
125
+ def modalities_for(model_id)
126
+ modalities = {
127
+ input: ['text'],
128
+ output: []
129
+ }
130
+
131
+ modalities[:output] << 'text' if model_type(model_id) == 'chat'
132
+
133
+ # Vision support
134
+ modalities[:input] << 'image' if supports_vision?(model_id)
135
+
136
+ modalities
137
+ end
138
+
139
+ def capabilities_for(model_id)
140
+ capabilities = []
141
+
142
+ # Common capabilities
143
+ capabilities << 'streaming'
144
+ capabilities << 'function_calling' if supports_functions?(model_id)
145
+ capabilities << 'structured_output' if supports_structured_output?(model_id)
146
+
147
+ capabilities
148
+ end
149
+
150
+ def pricing_for(model_id)
151
+ standard_pricing = {
152
+ input_per_million: input_price_for(model_id),
153
+ output_per_million: output_price_for(model_id)
154
+ }
155
+
156
+ # Add cached pricing if available
157
+ cached_price = cached_input_price_for(model_id)
158
+ standard_pricing[:cached_input_per_million] = cached_price if cached_price
159
+
160
+ # Pricing structure
161
+ { text_tokens: { standard: standard_pricing } }
162
+ end
163
+ end
164
+ end
165
+ end
166
+ end
@@ -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,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Providers
5
+ class XAI
6
+ # Model definitions for xAI API
7
+ # https://docs.x.ai/docs/api-reference#list-language-models
8
+ # https://docs.x.ai/docs/api-reference#list-image-generation-models
9
+ module Models
10
+ module_function
11
+
12
+ # NOTE: We pull models list from two endpoints here as these provide
13
+ # detailed, modality, capability and cost information for each
14
+ # model that we can leverage which the generic OpenAI compatible
15
+ # /models endpoint does not provide.
16
+ def models_url
17
+ %w[language-models image-generation-models]
18
+ end
19
+
20
+ def parse_list_models_response(response, slug, capabilities)
21
+ data = response.body
22
+ return [] if data.empty?
23
+
24
+ data['models']&.map do |model_data|
25
+ model_id = model_data['id']
26
+
27
+ Model::Info.new(
28
+ id: model_id,
29
+ name: capabilities.format_display_name(model_id),
30
+ provider: slug,
31
+ family: capabilities.model_family(model_id),
32
+ modalities: {
33
+ input: model_data['input_modalities'] | capabilities.modalities_for(model_id)[:input],
34
+ output: model_data['output_modalities'] | capabilities.modalities_for(model_id)[:output]
35
+ },
36
+ context_window: capabilities.context_window_for(model_id),
37
+ capabilities: capabilities.capabilities_for(model_id),
38
+ pricing: capabilities.pricing_for(model_id),
39
+ metadata: {
40
+ aliases: model_data['aliases']
41
+ }
42
+ )
43
+ end || []
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Providers
5
+ # xAI API integration
6
+ class XAI < OpenAIBase
7
+ include XAI::Capabilities
8
+ include XAI::Chat
9
+ include XAI::Models
10
+
11
+ def api_base
12
+ 'https://api.x.ai/v1'
13
+ end
14
+
15
+ def headers
16
+ {
17
+ 'Authorization' => "Bearer #{@config.xai_api_key}",
18
+ 'Content-Type' => 'application/json'
19
+ }
20
+ end
21
+
22
+ # xAI uses a different error format than OpenAI
23
+ # {"code": "...", "error": "..."}
24
+ def parse_error(response)
25
+ return if response.body.empty?
26
+
27
+ body = try_parse_json(response.body)
28
+ case body
29
+ when Hash then body['error']
30
+ when Array then body.map { |part| part['error'] }.join('. ')
31
+ else body
32
+ end
33
+ end
34
+
35
+ class << self
36
+ def capabilities
37
+ XAI::Capabilities
38
+ end
39
+
40
+ def configuration_requirements
41
+ %i[xai_api_key]
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end
@@ -3,15 +3,31 @@
3
3
  module RubyLLM
4
4
  # Rails integration for RubyLLM
5
5
  class Railtie < Rails::Railtie
6
+ initializer 'ruby_llm.inflections' do
7
+ ActiveSupport::Inflector.inflections(:en) do |inflect|
8
+ inflect.acronym 'LLM'
9
+ end
10
+ end
11
+
6
12
  initializer 'ruby_llm.active_record' do
7
13
  ActiveSupport.on_load :active_record do
8
- include RubyLLM::ActiveRecord::ActsAs
14
+ if RubyLLM.config.use_new_acts_as
15
+ require 'ruby_llm/active_record/acts_as'
16
+ ::ActiveRecord::Base.include RubyLLM::ActiveRecord::ActsAs
17
+ else
18
+ require 'ruby_llm/active_record/acts_as_legacy'
19
+ ::ActiveRecord::Base.include RubyLLM::ActiveRecord::ActsAsLegacy
20
+
21
+ Rails.logger.warn(
22
+ "\n!!! RubyLLM's legacy acts_as API is deprecated and will be removed in RubyLLM 2.0.0. " \
23
+ "Please consult the migration guide at https://rubyllm.com/upgrading-to-1-7/\n"
24
+ )
25
+ end
9
26
  end
10
27
  end
11
28
 
12
- # Register generators
13
- generators do
14
- require 'generators/ruby_llm/install_generator'
29
+ rake_tasks do
30
+ load 'tasks/ruby_llm.rake'
15
31
  end
16
32
  end
17
33
  end
@@ -2,29 +2,29 @@
2
2
 
3
3
  module RubyLLM
4
4
  # Assembles streaming responses from LLMs into complete messages.
5
- # Handles the complexities of accumulating content and tool calls
6
- # from partial chunks while tracking token usage.
7
5
  class StreamAccumulator
8
6
  attr_reader :content, :model_id, :tool_calls
9
7
 
10
8
  def initialize
11
- @content = +''
9
+ @content = nil
12
10
  @tool_calls = {}
13
11
  @input_tokens = 0
14
12
  @output_tokens = 0
15
13
  @cached_tokens = 0
16
14
  @cache_creation_tokens = 0
17
15
  @latest_tool_call_id = nil
16
+ @reasoning_id = nil
18
17
  end
19
18
 
20
19
  def add(chunk)
21
20
  RubyLLM.logger.debug chunk.inspect if RubyLLM.config.log_stream_debug
22
21
  @model_id ||= chunk.model_id
22
+ @reasoning_id ||= chunk.reasoning_id
23
23
 
24
24
  if chunk.tool_call?
25
25
  accumulate_tool_calls chunk.tool_calls
26
26
  else
27
- @content << (chunk.content || '')
27
+ accumulate_content(chunk.content)
28
28
  end
29
29
 
30
30
  count_tokens chunk
@@ -32,27 +32,85 @@ module RubyLLM
32
32
  end
33
33
 
34
34
  def to_message(response)
35
+ content = final_content
36
+ associate_reasoning_with_images(content)
37
+
35
38
  Message.new(
36
39
  role: :assistant,
37
- content: content.empty? ? nil : content,
40
+ content: content,
38
41
  model_id: model_id,
39
42
  tool_calls: tool_calls_from_stream,
40
- input_tokens: @input_tokens.positive? ? @input_tokens : nil,
41
- output_tokens: @output_tokens.positive? ? @output_tokens : nil,
42
- cached_tokens: @cached_tokens.positive? ? @cached_tokens : nil,
43
- cache_creation_tokens: @cache_creation_tokens.positive? ? @cache_creation_tokens : nil,
43
+ input_tokens: positive_or_nil(@input_tokens),
44
+ output_tokens: positive_or_nil(@output_tokens),
45
+ cached_tokens: positive_or_nil(@cached_tokens),
46
+ cache_creation_tokens: positive_or_nil(@cache_creation_tokens),
44
47
  raw: response
45
48
  )
46
49
  end
47
50
 
48
51
  private
49
52
 
53
+ def associate_reasoning_with_images(content)
54
+ return unless @reasoning_id && content.is_a?(Content) && content.attachments.any?
55
+
56
+ content.attachments.each do |attachment|
57
+ attachment.instance_variable_set(:@reasoning_id, @reasoning_id) if attachment.is_a?(ImageAttachment)
58
+ end
59
+ end
60
+
61
+ def positive_or_nil(value)
62
+ value.positive? ? value : nil
63
+ end
64
+
65
+ def accumulate_content(new_content)
66
+ return unless new_content
67
+
68
+ if @content.nil?
69
+ @content = new_content.is_a?(String) ? +new_content : new_content
70
+ else
71
+ case [@content.class, new_content.class]
72
+ when [String, String]
73
+ @content << new_content
74
+ when [String, Content]
75
+ @content = Content.new(@content)
76
+ merge_content(new_content)
77
+ when [Content, String]
78
+ @content.instance_variable_set(:@text, (@content.text || '') + new_content)
79
+ when [Content, Content]
80
+ merge_content(new_content)
81
+ end
82
+ end
83
+ end
84
+
85
+ def merge_content(new_content)
86
+ current_text = @content.text || ''
87
+ new_text = new_content.text || ''
88
+ @content.instance_variable_set(:@text, current_text + new_text)
89
+
90
+ new_content.attachments.each do |attachment|
91
+ @content.attach(attachment)
92
+ end
93
+ end
94
+
95
+ def final_content
96
+ case @content
97
+ when nil
98
+ nil
99
+ when String
100
+ @content.empty? ? nil : @content
101
+ when Content
102
+ @content.text.nil? && @content.attachments.empty? ? nil : @content
103
+ else
104
+ @content
105
+ end
106
+ end
107
+
50
108
  def tool_calls_from_stream
51
109
  tool_calls.transform_values do |tc|
52
110
  arguments = if tc.arguments.is_a?(String) && !tc.arguments.empty?
53
111
  JSON.parse(tc.arguments)
54
112
  elsif tc.arguments.is_a?(String)
55
- {} # Return empty hash for empty string arguments
113
+ {}
56
114
  else
57
115
  tc.arguments
58
116
  end