dify_llm 1.6.4

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 (129) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +157 -0
  4. data/lib/generators/ruby_llm/install/templates/chat_model.rb.tt +3 -0
  5. data/lib/generators/ruby_llm/install/templates/create_chats_legacy_migration.rb.tt +8 -0
  6. data/lib/generators/ruby_llm/install/templates/create_chats_migration.rb.tt +8 -0
  7. data/lib/generators/ruby_llm/install/templates/create_messages_legacy_migration.rb.tt +16 -0
  8. data/lib/generators/ruby_llm/install/templates/create_messages_migration.rb.tt +16 -0
  9. data/lib/generators/ruby_llm/install/templates/create_models_migration.rb.tt +43 -0
  10. data/lib/generators/ruby_llm/install/templates/create_tool_calls_migration.rb.tt +15 -0
  11. data/lib/generators/ruby_llm/install/templates/initializer.rb.tt +9 -0
  12. data/lib/generators/ruby_llm/install/templates/message_model.rb.tt +4 -0
  13. data/lib/generators/ruby_llm/install/templates/model_model.rb.tt +3 -0
  14. data/lib/generators/ruby_llm/install/templates/tool_call_model.rb.tt +3 -0
  15. data/lib/generators/ruby_llm/install_generator.rb +184 -0
  16. data/lib/generators/ruby_llm/migrate_model_fields/templates/migration.rb.tt +142 -0
  17. data/lib/generators/ruby_llm/migrate_model_fields_generator.rb +84 -0
  18. data/lib/ruby_llm/active_record/acts_as.rb +137 -0
  19. data/lib/ruby_llm/active_record/acts_as_legacy.rb +398 -0
  20. data/lib/ruby_llm/active_record/chat_methods.rb +315 -0
  21. data/lib/ruby_llm/active_record/message_methods.rb +72 -0
  22. data/lib/ruby_llm/active_record/model_methods.rb +84 -0
  23. data/lib/ruby_llm/aliases.json +274 -0
  24. data/lib/ruby_llm/aliases.rb +38 -0
  25. data/lib/ruby_llm/attachment.rb +191 -0
  26. data/lib/ruby_llm/chat.rb +212 -0
  27. data/lib/ruby_llm/chunk.rb +6 -0
  28. data/lib/ruby_llm/configuration.rb +69 -0
  29. data/lib/ruby_llm/connection.rb +137 -0
  30. data/lib/ruby_llm/content.rb +50 -0
  31. data/lib/ruby_llm/context.rb +29 -0
  32. data/lib/ruby_llm/embedding.rb +29 -0
  33. data/lib/ruby_llm/error.rb +76 -0
  34. data/lib/ruby_llm/image.rb +49 -0
  35. data/lib/ruby_llm/message.rb +76 -0
  36. data/lib/ruby_llm/mime_type.rb +67 -0
  37. data/lib/ruby_llm/model/info.rb +103 -0
  38. data/lib/ruby_llm/model/modalities.rb +22 -0
  39. data/lib/ruby_llm/model/pricing.rb +48 -0
  40. data/lib/ruby_llm/model/pricing_category.rb +46 -0
  41. data/lib/ruby_llm/model/pricing_tier.rb +33 -0
  42. data/lib/ruby_llm/model.rb +7 -0
  43. data/lib/ruby_llm/models.json +31418 -0
  44. data/lib/ruby_llm/models.rb +235 -0
  45. data/lib/ruby_llm/models_schema.json +168 -0
  46. data/lib/ruby_llm/provider.rb +215 -0
  47. data/lib/ruby_llm/providers/anthropic/capabilities.rb +134 -0
  48. data/lib/ruby_llm/providers/anthropic/chat.rb +106 -0
  49. data/lib/ruby_llm/providers/anthropic/embeddings.rb +20 -0
  50. data/lib/ruby_llm/providers/anthropic/media.rb +91 -0
  51. data/lib/ruby_llm/providers/anthropic/models.rb +48 -0
  52. data/lib/ruby_llm/providers/anthropic/streaming.rb +43 -0
  53. data/lib/ruby_llm/providers/anthropic/tools.rb +107 -0
  54. data/lib/ruby_llm/providers/anthropic.rb +36 -0
  55. data/lib/ruby_llm/providers/bedrock/capabilities.rb +167 -0
  56. data/lib/ruby_llm/providers/bedrock/chat.rb +63 -0
  57. data/lib/ruby_llm/providers/bedrock/media.rb +60 -0
  58. data/lib/ruby_llm/providers/bedrock/models.rb +98 -0
  59. data/lib/ruby_llm/providers/bedrock/signing.rb +831 -0
  60. data/lib/ruby_llm/providers/bedrock/streaming/base.rb +51 -0
  61. data/lib/ruby_llm/providers/bedrock/streaming/content_extraction.rb +56 -0
  62. data/lib/ruby_llm/providers/bedrock/streaming/message_processing.rb +67 -0
  63. data/lib/ruby_llm/providers/bedrock/streaming/payload_processing.rb +78 -0
  64. data/lib/ruby_llm/providers/bedrock/streaming/prelude_handling.rb +78 -0
  65. data/lib/ruby_llm/providers/bedrock/streaming.rb +18 -0
  66. data/lib/ruby_llm/providers/bedrock.rb +82 -0
  67. data/lib/ruby_llm/providers/deepseek/capabilities.rb +130 -0
  68. data/lib/ruby_llm/providers/deepseek/chat.rb +16 -0
  69. data/lib/ruby_llm/providers/deepseek.rb +30 -0
  70. data/lib/ruby_llm/providers/dify/capabilities.rb +16 -0
  71. data/lib/ruby_llm/providers/dify/chat.rb +59 -0
  72. data/lib/ruby_llm/providers/dify/media.rb +37 -0
  73. data/lib/ruby_llm/providers/dify/streaming.rb +28 -0
  74. data/lib/ruby_llm/providers/dify.rb +48 -0
  75. data/lib/ruby_llm/providers/gemini/capabilities.rb +276 -0
  76. data/lib/ruby_llm/providers/gemini/chat.rb +171 -0
  77. data/lib/ruby_llm/providers/gemini/embeddings.rb +37 -0
  78. data/lib/ruby_llm/providers/gemini/images.rb +47 -0
  79. data/lib/ruby_llm/providers/gemini/media.rb +54 -0
  80. data/lib/ruby_llm/providers/gemini/models.rb +40 -0
  81. data/lib/ruby_llm/providers/gemini/streaming.rb +61 -0
  82. data/lib/ruby_llm/providers/gemini/tools.rb +77 -0
  83. data/lib/ruby_llm/providers/gemini.rb +36 -0
  84. data/lib/ruby_llm/providers/gpustack/chat.rb +27 -0
  85. data/lib/ruby_llm/providers/gpustack/media.rb +45 -0
  86. data/lib/ruby_llm/providers/gpustack/models.rb +90 -0
  87. data/lib/ruby_llm/providers/gpustack.rb +34 -0
  88. data/lib/ruby_llm/providers/mistral/capabilities.rb +155 -0
  89. data/lib/ruby_llm/providers/mistral/chat.rb +24 -0
  90. data/lib/ruby_llm/providers/mistral/embeddings.rb +33 -0
  91. data/lib/ruby_llm/providers/mistral/models.rb +48 -0
  92. data/lib/ruby_llm/providers/mistral.rb +32 -0
  93. data/lib/ruby_llm/providers/ollama/chat.rb +27 -0
  94. data/lib/ruby_llm/providers/ollama/media.rb +45 -0
  95. data/lib/ruby_llm/providers/ollama/models.rb +36 -0
  96. data/lib/ruby_llm/providers/ollama.rb +30 -0
  97. data/lib/ruby_llm/providers/openai/capabilities.rb +291 -0
  98. data/lib/ruby_llm/providers/openai/chat.rb +83 -0
  99. data/lib/ruby_llm/providers/openai/embeddings.rb +33 -0
  100. data/lib/ruby_llm/providers/openai/images.rb +38 -0
  101. data/lib/ruby_llm/providers/openai/media.rb +80 -0
  102. data/lib/ruby_llm/providers/openai/models.rb +39 -0
  103. data/lib/ruby_llm/providers/openai/streaming.rb +41 -0
  104. data/lib/ruby_llm/providers/openai/tools.rb +78 -0
  105. data/lib/ruby_llm/providers/openai.rb +42 -0
  106. data/lib/ruby_llm/providers/openrouter/models.rb +73 -0
  107. data/lib/ruby_llm/providers/openrouter.rb +26 -0
  108. data/lib/ruby_llm/providers/perplexity/capabilities.rb +137 -0
  109. data/lib/ruby_llm/providers/perplexity/chat.rb +16 -0
  110. data/lib/ruby_llm/providers/perplexity/models.rb +42 -0
  111. data/lib/ruby_llm/providers/perplexity.rb +48 -0
  112. data/lib/ruby_llm/providers/vertexai/chat.rb +14 -0
  113. data/lib/ruby_llm/providers/vertexai/embeddings.rb +32 -0
  114. data/lib/ruby_llm/providers/vertexai/models.rb +130 -0
  115. data/lib/ruby_llm/providers/vertexai/streaming.rb +14 -0
  116. data/lib/ruby_llm/providers/vertexai.rb +55 -0
  117. data/lib/ruby_llm/railtie.rb +41 -0
  118. data/lib/ruby_llm/stream_accumulator.rb +97 -0
  119. data/lib/ruby_llm/streaming.rb +153 -0
  120. data/lib/ruby_llm/tool.rb +83 -0
  121. data/lib/ruby_llm/tool_call.rb +22 -0
  122. data/lib/ruby_llm/utils.rb +45 -0
  123. data/lib/ruby_llm/version.rb +5 -0
  124. data/lib/ruby_llm.rb +97 -0
  125. data/lib/tasks/models.rake +525 -0
  126. data/lib/tasks/release.rake +67 -0
  127. data/lib/tasks/ruby_llm.rake +15 -0
  128. data/lib/tasks/vcr.rake +92 -0
  129. metadata +291 -0
@@ -0,0 +1,274 @@
1
+ {
2
+ "chatgpt-4o": {
3
+ "openai": "chatgpt-4o-latest",
4
+ "openrouter": "openai/chatgpt-4o-latest"
5
+ },
6
+ "claude-3-5-haiku": {
7
+ "anthropic": "claude-3-5-haiku-20241022",
8
+ "openrouter": "anthropic/claude-3.5-haiku",
9
+ "bedrock": "anthropic.claude-3-5-haiku-20241022-v1:0"
10
+ },
11
+ "claude-3-5-sonnet": {
12
+ "anthropic": "claude-3-5-sonnet-20241022",
13
+ "openrouter": "anthropic/claude-3.5-sonnet",
14
+ "bedrock": "anthropic.claude-3-5-sonnet-20240620-v1:0:200k"
15
+ },
16
+ "claude-3-7-sonnet": {
17
+ "anthropic": "claude-3-7-sonnet-20250219",
18
+ "openrouter": "anthropic/claude-3.7-sonnet",
19
+ "bedrock": "us.anthropic.claude-3-7-sonnet-20250219-v1:0"
20
+ },
21
+ "claude-3-haiku": {
22
+ "anthropic": "claude-3-haiku-20240307",
23
+ "openrouter": "anthropic/claude-3-haiku",
24
+ "bedrock": "anthropic.claude-3-haiku-20240307-v1:0:200k"
25
+ },
26
+ "claude-3-opus": {
27
+ "anthropic": "claude-3-opus-20240229",
28
+ "openrouter": "anthropic/claude-3-opus",
29
+ "bedrock": "anthropic.claude-3-opus-20240229-v1:0:200k"
30
+ },
31
+ "claude-3-sonnet": {
32
+ "bedrock": "anthropic.claude-3-sonnet-20240229-v1:0"
33
+ },
34
+ "claude-opus-4": {
35
+ "anthropic": "claude-opus-4-20250514",
36
+ "openrouter": "anthropic/claude-opus-4",
37
+ "bedrock": "us.anthropic.claude-opus-4-1-20250805-v1:0"
38
+ },
39
+ "claude-opus-4-1": {
40
+ "anthropic": "claude-opus-4-1-20250805",
41
+ "openrouter": "anthropic/claude-opus-4.1",
42
+ "bedrock": "us.anthropic.claude-opus-4-1-20250805-v1:0"
43
+ },
44
+ "claude-sonnet-4": {
45
+ "anthropic": "claude-sonnet-4-20250514",
46
+ "openrouter": "anthropic/claude-sonnet-4",
47
+ "bedrock": "us.anthropic.claude-sonnet-4-20250514-v1:0"
48
+ },
49
+ "deepseek-chat": {
50
+ "deepseek": "deepseek-chat",
51
+ "openrouter": "deepseek/deepseek-chat"
52
+ },
53
+ "gemini-1.5-flash": {
54
+ "gemini": "gemini-1.5-flash",
55
+ "vertexai": "gemini-1.5-flash"
56
+ },
57
+ "gemini-1.5-flash-002": {
58
+ "gemini": "gemini-1.5-flash-002",
59
+ "vertexai": "gemini-1.5-flash-002"
60
+ },
61
+ "gemini-1.5-flash-8b": {
62
+ "gemini": "gemini-1.5-flash-8b",
63
+ "vertexai": "gemini-1.5-flash-8b"
64
+ },
65
+ "gemini-1.5-pro": {
66
+ "gemini": "gemini-1.5-pro",
67
+ "vertexai": "gemini-1.5-pro"
68
+ },
69
+ "gemini-1.5-pro-002": {
70
+ "gemini": "gemini-1.5-pro-002",
71
+ "vertexai": "gemini-1.5-pro-002"
72
+ },
73
+ "gemini-2.0-flash": {
74
+ "gemini": "gemini-2.0-flash",
75
+ "vertexai": "gemini-2.0-flash"
76
+ },
77
+ "gemini-2.0-flash-001": {
78
+ "gemini": "gemini-2.0-flash-001",
79
+ "openrouter": "google/gemini-2.0-flash-001",
80
+ "vertexai": "gemini-2.0-flash-001"
81
+ },
82
+ "gemini-2.0-flash-exp": {
83
+ "gemini": "gemini-2.0-flash-exp",
84
+ "vertexai": "gemini-2.0-flash-exp"
85
+ },
86
+ "gemini-2.0-flash-lite-001": {
87
+ "gemini": "gemini-2.0-flash-lite-001",
88
+ "openrouter": "google/gemini-2.0-flash-lite-001",
89
+ "vertexai": "gemini-2.0-flash-lite-001"
90
+ },
91
+ "gemini-2.5-flash": {
92
+ "gemini": "gemini-2.5-flash",
93
+ "openrouter": "google/gemini-2.5-flash",
94
+ "vertexai": "gemini-2.5-flash"
95
+ },
96
+ "gemini-2.5-flash-image-preview": {
97
+ "gemini": "gemini-2.5-flash-image-preview",
98
+ "openrouter": "google/gemini-2.5-flash-image-preview"
99
+ },
100
+ "gemini-2.5-flash-lite": {
101
+ "gemini": "gemini-2.5-flash-lite",
102
+ "openrouter": "google/gemini-2.5-flash-lite",
103
+ "vertexai": "gemini-2.5-flash-lite"
104
+ },
105
+ "gemini-2.5-flash-lite-preview-06-17": {
106
+ "gemini": "gemini-2.5-flash-lite-preview-06-17",
107
+ "openrouter": "google/gemini-2.5-flash-lite-preview-06-17"
108
+ },
109
+ "gemini-2.5-pro": {
110
+ "gemini": "gemini-2.5-pro",
111
+ "openrouter": "google/gemini-2.5-pro",
112
+ "vertexai": "gemini-2.5-pro"
113
+ },
114
+ "gemini-2.5-pro-preview-05-06": {
115
+ "gemini": "gemini-2.5-pro-preview-05-06",
116
+ "openrouter": "google/gemini-2.5-pro-preview-05-06"
117
+ },
118
+ "gemini-embedding-001": {
119
+ "gemini": "gemini-embedding-001",
120
+ "vertexai": "gemini-embedding-001"
121
+ },
122
+ "gemini-exp-1206": {
123
+ "gemini": "gemini-exp-1206",
124
+ "vertexai": "gemini-exp-1206"
125
+ },
126
+ "gemma-3-12b-it": {
127
+ "gemini": "gemma-3-12b-it",
128
+ "openrouter": "google/gemma-3-12b-it"
129
+ },
130
+ "gemma-3-27b-it": {
131
+ "gemini": "gemma-3-27b-it",
132
+ "openrouter": "google/gemma-3-27b-it"
133
+ },
134
+ "gemma-3-4b-it": {
135
+ "gemini": "gemma-3-4b-it",
136
+ "openrouter": "google/gemma-3-4b-it"
137
+ },
138
+ "gemma-3n-e4b-it": {
139
+ "gemini": "gemma-3n-e4b-it",
140
+ "openrouter": "google/gemma-3n-e4b-it"
141
+ },
142
+ "gpt-3.5-turbo": {
143
+ "openai": "gpt-3.5-turbo",
144
+ "openrouter": "openai/gpt-3.5-turbo"
145
+ },
146
+ "gpt-3.5-turbo-16k": {
147
+ "openai": "gpt-3.5-turbo-16k",
148
+ "openrouter": "openai/gpt-3.5-turbo-16k"
149
+ },
150
+ "gpt-3.5-turbo-instruct": {
151
+ "openai": "gpt-3.5-turbo-instruct",
152
+ "openrouter": "openai/gpt-3.5-turbo-instruct"
153
+ },
154
+ "gpt-4": {
155
+ "openai": "gpt-4",
156
+ "openrouter": "openai/gpt-4"
157
+ },
158
+ "gpt-4-1106-preview": {
159
+ "openai": "gpt-4-1106-preview",
160
+ "openrouter": "openai/gpt-4-1106-preview"
161
+ },
162
+ "gpt-4-turbo": {
163
+ "openai": "gpt-4-turbo",
164
+ "openrouter": "openai/gpt-4-turbo"
165
+ },
166
+ "gpt-4-turbo-preview": {
167
+ "openai": "gpt-4-turbo-preview",
168
+ "openrouter": "openai/gpt-4-turbo-preview"
169
+ },
170
+ "gpt-4.1": {
171
+ "openai": "gpt-4.1",
172
+ "openrouter": "openai/gpt-4.1"
173
+ },
174
+ "gpt-4.1-mini": {
175
+ "openai": "gpt-4.1-mini",
176
+ "openrouter": "openai/gpt-4.1-mini"
177
+ },
178
+ "gpt-4.1-nano": {
179
+ "openai": "gpt-4.1-nano",
180
+ "openrouter": "openai/gpt-4.1-nano"
181
+ },
182
+ "gpt-4o": {
183
+ "openai": "gpt-4o",
184
+ "openrouter": "openai/gpt-4o"
185
+ },
186
+ "gpt-4o-2024-05-13": {
187
+ "openai": "gpt-4o-2024-05-13",
188
+ "openrouter": "openai/gpt-4o-2024-05-13"
189
+ },
190
+ "gpt-4o-2024-08-06": {
191
+ "openai": "gpt-4o-2024-08-06",
192
+ "openrouter": "openai/gpt-4o-2024-08-06"
193
+ },
194
+ "gpt-4o-2024-11-20": {
195
+ "openai": "gpt-4o-2024-11-20",
196
+ "openrouter": "openai/gpt-4o-2024-11-20"
197
+ },
198
+ "gpt-4o-audio-preview": {
199
+ "openai": "gpt-4o-audio-preview",
200
+ "openrouter": "openai/gpt-4o-audio-preview"
201
+ },
202
+ "gpt-4o-mini": {
203
+ "openai": "gpt-4o-mini",
204
+ "openrouter": "openai/gpt-4o-mini"
205
+ },
206
+ "gpt-4o-mini-2024-07-18": {
207
+ "openai": "gpt-4o-mini-2024-07-18",
208
+ "openrouter": "openai/gpt-4o-mini-2024-07-18"
209
+ },
210
+ "gpt-4o-mini-search-preview": {
211
+ "openai": "gpt-4o-mini-search-preview",
212
+ "openrouter": "openai/gpt-4o-mini-search-preview"
213
+ },
214
+ "gpt-4o-search-preview": {
215
+ "openai": "gpt-4o-search-preview",
216
+ "openrouter": "openai/gpt-4o-search-preview"
217
+ },
218
+ "gpt-5": {
219
+ "openai": "gpt-5",
220
+ "openrouter": "openai/gpt-5"
221
+ },
222
+ "gpt-5-mini": {
223
+ "openai": "gpt-5-mini",
224
+ "openrouter": "openai/gpt-5-mini"
225
+ },
226
+ "gpt-5-nano": {
227
+ "openai": "gpt-5-nano",
228
+ "openrouter": "openai/gpt-5-nano"
229
+ },
230
+ "gpt-oss-120b": {
231
+ "openai": "gpt-oss-120b",
232
+ "openrouter": "openai/gpt-oss-120b"
233
+ },
234
+ "gpt-oss-20b": {
235
+ "openai": "gpt-oss-20b",
236
+ "openrouter": "openai/gpt-oss-20b"
237
+ },
238
+ "o1": {
239
+ "openai": "o1",
240
+ "openrouter": "openai/o1"
241
+ },
242
+ "o1-mini": {
243
+ "openai": "o1-mini",
244
+ "openrouter": "openai/o1-mini"
245
+ },
246
+ "o1-mini-2024-09-12": {
247
+ "openai": "o1-mini-2024-09-12",
248
+ "openrouter": "openai/o1-mini-2024-09-12"
249
+ },
250
+ "o1-pro": {
251
+ "openai": "o1-pro",
252
+ "openrouter": "openai/o1-pro"
253
+ },
254
+ "o3": {
255
+ "openai": "o3",
256
+ "openrouter": "openai/o3"
257
+ },
258
+ "o3-mini": {
259
+ "openai": "o3-mini",
260
+ "openrouter": "openai/o3-mini"
261
+ },
262
+ "o3-pro": {
263
+ "openai": "o3-pro",
264
+ "openrouter": "openai/o3-pro"
265
+ },
266
+ "o4-mini": {
267
+ "openai": "o4-mini",
268
+ "openrouter": "openai/o4-mini"
269
+ },
270
+ "text-embedding-004": {
271
+ "gemini": "text-embedding-004",
272
+ "vertexai": "text-embedding-004"
273
+ }
274
+ }
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ # Manages model aliases for provider-specific versions
5
+ class Aliases
6
+ class << self
7
+ def resolve(model_id, provider = nil)
8
+ return model_id unless aliases[model_id]
9
+
10
+ if provider
11
+ aliases[model_id][provider.to_s] || model_id
12
+ else
13
+ aliases[model_id].values.first || model_id
14
+ end
15
+ end
16
+
17
+ def aliases
18
+ @aliases ||= load_aliases
19
+ end
20
+
21
+ def aliases_file
22
+ File.expand_path('aliases.json', __dir__)
23
+ end
24
+
25
+ def load_aliases
26
+ if File.exist?(aliases_file)
27
+ JSON.parse(File.read(aliases_file))
28
+ else
29
+ {}
30
+ end
31
+ end
32
+
33
+ def reload!
34
+ @aliases = load_aliases
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,191 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ # A class representing a file attachment.
5
+ class Attachment
6
+ attr_reader :source, :filename, :mime_type, :upload_file_id
7
+
8
+ def initialize(source, filename: nil)
9
+ @source = source
10
+ if url?
11
+ @source = URI source
12
+ @filename = filename || File.basename(@source.path).to_s
13
+ elsif uuid?
14
+ @upload_file_id = source
15
+ elsif path?
16
+ @source = Pathname.new source
17
+ @filename = filename || @source.basename.to_s
18
+ elsif active_storage?
19
+ @filename = filename || extract_filename_from_active_storage
20
+ else
21
+ @filename = filename
22
+ end
23
+
24
+ determine_mime_type if @upload_file_id.nil?
25
+ end
26
+
27
+ def url?
28
+ @source.is_a?(URI) || (@source.is_a?(String) && @source.match?(%r{^https?://}))
29
+ end
30
+
31
+ def path?
32
+ @source.is_a?(Pathname) || (@source.is_a?(String) && !url?)
33
+ end
34
+
35
+ def io_like?
36
+ @source.respond_to?(:read) && !path? && !active_storage?
37
+ end
38
+
39
+ def active_storage?
40
+ return false unless defined?(ActiveStorage)
41
+
42
+ @source.is_a?(ActiveStorage::Blob) ||
43
+ @source.is_a?(ActiveStorage::Attached::One) ||
44
+ @source.is_a?(ActiveStorage::Attached::Many)
45
+ end
46
+
47
+ def uuid?
48
+ @source.is_a?(String) && @source.match?(/\A[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z/i)
49
+ end
50
+
51
+ def content
52
+ return @content if defined?(@content) && !@content.nil?
53
+
54
+ if url?
55
+ fetch_content
56
+ elsif path?
57
+ load_content_from_path
58
+ elsif active_storage?
59
+ load_content_from_active_storage
60
+ elsif io_like?
61
+ load_content_from_io
62
+ else
63
+ RubyLLM.logger.warn "Source is neither a URL, path, ActiveStorage, nor IO-like: #{@source.class}"
64
+ nil
65
+ end
66
+
67
+ @content
68
+ end
69
+
70
+ def encoded
71
+ Base64.strict_encode64(content)
72
+ end
73
+
74
+ def for_llm
75
+ case type
76
+ when :text
77
+ "<file name='#{filename}' mime_type='#{mime_type}'>#{content}</file>"
78
+ else
79
+ "data:#{mime_type};base64,#{encoded}"
80
+ end
81
+ end
82
+
83
+ def type
84
+ return :file_id unless @upload_file_id.nil?
85
+ return :image if image?
86
+ return :audio if audio?
87
+ return :pdf if pdf?
88
+ return :text if text?
89
+
90
+ :unknown
91
+ end
92
+
93
+ def image?
94
+ RubyLLM::MimeType.image? mime_type
95
+ end
96
+
97
+ def audio?
98
+ RubyLLM::MimeType.audio? mime_type
99
+ end
100
+
101
+ def format
102
+ case mime_type
103
+ when 'audio/mpeg'
104
+ 'mp3'
105
+ when 'audio/wav', 'audio/wave', 'audio/x-wav'
106
+ 'wav'
107
+ else
108
+ mime_type.split('/').last
109
+ end
110
+ end
111
+
112
+ def pdf?
113
+ RubyLLM::MimeType.pdf? mime_type
114
+ end
115
+
116
+ def text?
117
+ RubyLLM::MimeType.text? mime_type
118
+ end
119
+
120
+ def to_h
121
+ { type: type, source: @source }
122
+ end
123
+
124
+ private
125
+
126
+ def determine_mime_type
127
+ return @mime_type = active_storage_content_type if active_storage? && active_storage_content_type.present?
128
+
129
+ @mime_type = RubyLLM::MimeType.for(url? ? nil : @source, name: @filename)
130
+ @mime_type = RubyLLM::MimeType.for(content) if @mime_type == 'application/octet-stream'
131
+ @mime_type = 'audio/wav' if @mime_type == 'audio/x-wav' # Normalize WAV type
132
+ end
133
+
134
+ def fetch_content
135
+ response = Connection.basic.get @source.to_s
136
+ @content = response.body
137
+ end
138
+
139
+ def load_content_from_path
140
+ @content = File.read(@source)
141
+ end
142
+
143
+ def load_content_from_io
144
+ @source.rewind if @source.respond_to? :rewind
145
+ @content = @source.read
146
+ end
147
+
148
+ def load_content_from_active_storage
149
+ return unless defined?(ActiveStorage)
150
+
151
+ @content = case @source
152
+ when ActiveStorage::Blob
153
+ @source.download
154
+ when ActiveStorage::Attached::One
155
+ @source.blob&.download
156
+ when ActiveStorage::Attached::Many
157
+ # For multiple attachments, just take the first one
158
+ # This maintains the single-attachment interface
159
+ @source.blobs.first&.download
160
+ end
161
+ end
162
+
163
+ def extract_filename_from_active_storage # rubocop:disable Metrics/PerceivedComplexity
164
+ return 'attachment' unless defined?(ActiveStorage)
165
+
166
+ case @source
167
+ when ActiveStorage::Blob
168
+ @source.filename.to_s
169
+ when ActiveStorage::Attached::One
170
+ @source.blob&.filename&.to_s || 'attachment'
171
+ when ActiveStorage::Attached::Many
172
+ @source.blobs.first&.filename&.to_s || 'attachment'
173
+ else
174
+ 'attachment'
175
+ end
176
+ end
177
+
178
+ def active_storage_content_type
179
+ return unless defined?(ActiveStorage)
180
+
181
+ case @source
182
+ when ActiveStorage::Blob
183
+ @source.content_type
184
+ when ActiveStorage::Attached::One
185
+ @source.blob&.content_type
186
+ when ActiveStorage::Attached::Many
187
+ @source.blobs.first&.content_type
188
+ end
189
+ end
190
+ end
191
+ end
@@ -0,0 +1,212 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ # Represents a conversation with an AI model
5
+ class Chat
6
+ include Enumerable
7
+
8
+ attr_reader :model, :messages, :tools, :params, :headers, :schema, :provider
9
+
10
+ def initialize(model: nil, provider: nil, assume_model_exists: false, context: nil)
11
+ if assume_model_exists && !provider
12
+ raise ArgumentError, 'Provider must be specified if assume_model_exists is true'
13
+ end
14
+
15
+ @context = context
16
+ @config = context&.config || RubyLLM.config
17
+ model_id = model || @config.default_model
18
+ with_model(model_id, provider: provider, assume_exists: assume_model_exists)
19
+ @temperature = nil
20
+ @messages = []
21
+ @tools = {}
22
+ @params = {}
23
+ @headers = {}
24
+ @schema = nil
25
+ @on = {
26
+ new_message: nil,
27
+ end_message: nil,
28
+ tool_call: nil,
29
+ tool_result: nil
30
+ }
31
+ end
32
+
33
+ def ask(message = nil, with: nil, &)
34
+ add_message role: :user, content: Content.new(message, with)
35
+ complete(&)
36
+ end
37
+
38
+ alias say ask
39
+
40
+ def with_instructions(instructions, replace: false)
41
+ @messages = @messages.reject { |msg| msg.role == :system } if replace
42
+
43
+ add_message role: :system, content: instructions
44
+ self
45
+ end
46
+
47
+ def with_tool(tool)
48
+ tool_instance = tool.is_a?(Class) ? tool.new : tool
49
+ @tools[tool_instance.name.to_sym] = tool_instance
50
+ self
51
+ end
52
+
53
+ def with_tools(*tools, replace: false)
54
+ @tools.clear if replace
55
+ tools.compact.each { |tool| with_tool tool }
56
+ self
57
+ end
58
+
59
+ def with_model(model_id, provider: nil, assume_exists: false)
60
+ @model, @provider = Models.resolve(model_id, provider:, assume_exists:, config: @config)
61
+ @connection = @provider.connection
62
+ self
63
+ end
64
+
65
+ def with_temperature(temperature)
66
+ @temperature = temperature
67
+ self
68
+ end
69
+
70
+ def with_context(context)
71
+ @context = context
72
+ @config = context.config
73
+ with_model(@model.id, provider: @provider.slug, assume_exists: true)
74
+ self
75
+ end
76
+
77
+ def with_params(**params)
78
+ @params = params
79
+ self
80
+ end
81
+
82
+ def with_headers(**headers)
83
+ @headers = headers
84
+ self
85
+ end
86
+
87
+ def with_schema(schema)
88
+ schema_instance = schema.is_a?(Class) ? schema.new : schema
89
+
90
+ # Accept both RubyLLM::Schema instances and plain JSON schemas
91
+ @schema = if schema_instance.respond_to?(:to_json_schema)
92
+ schema_instance.to_json_schema[:schema]
93
+ else
94
+ schema_instance
95
+ end
96
+
97
+ self
98
+ end
99
+
100
+ def on_new_message(&block)
101
+ @on[:new_message] = block
102
+ self
103
+ end
104
+
105
+ def on_end_message(&block)
106
+ @on[:end_message] = block
107
+ self
108
+ end
109
+
110
+ def on_tool_call(&block)
111
+ @on[:tool_call] = block
112
+ self
113
+ end
114
+
115
+ def on_tool_result(&block)
116
+ @on[:tool_result] = block
117
+ self
118
+ end
119
+
120
+ def each(&)
121
+ messages.each(&)
122
+ end
123
+
124
+ def complete(&) # rubocop:disable Metrics/PerceivedComplexity
125
+ response = @provider.complete(
126
+ messages,
127
+ tools: @tools,
128
+ temperature: @temperature,
129
+ model: @model,
130
+ params: @params,
131
+ headers: @headers,
132
+ schema: @schema,
133
+ &wrap_streaming_block(&)
134
+ )
135
+
136
+ @on[:new_message]&.call unless block_given?
137
+
138
+ if @schema && response.content.is_a?(String)
139
+ begin
140
+ response.content = JSON.parse(response.content)
141
+ rescue JSON::ParserError
142
+ # If parsing fails, keep content as string
143
+ end
144
+ end
145
+
146
+ add_message response
147
+ @on[:end_message]&.call(response)
148
+
149
+ if response.tool_call?
150
+ handle_tool_calls(response, &)
151
+ else
152
+ response
153
+ end
154
+ end
155
+
156
+ def add_message(message_or_attributes)
157
+ message = message_or_attributes.is_a?(Message) ? message_or_attributes : Message.new(message_or_attributes)
158
+ messages << message
159
+ message
160
+ end
161
+
162
+ def reset_messages!
163
+ @messages.clear
164
+ end
165
+
166
+ def instance_variables
167
+ super - %i[@connection @config]
168
+ end
169
+
170
+ private
171
+
172
+ def wrap_streaming_block(&block)
173
+ return nil unless block_given?
174
+
175
+ first_chunk_received = false
176
+
177
+ proc do |chunk|
178
+ # Create message on first content chunk
179
+ unless first_chunk_received
180
+ first_chunk_received = true
181
+ @on[:new_message]&.call
182
+ end
183
+
184
+ block.call chunk
185
+ end
186
+ end
187
+
188
+ def handle_tool_calls(response, &) # rubocop:disable Metrics/PerceivedComplexity
189
+ halt_result = nil
190
+
191
+ response.tool_calls.each_value do |tool_call|
192
+ @on[:new_message]&.call
193
+ @on[:tool_call]&.call(tool_call)
194
+ result = execute_tool tool_call
195
+ @on[:tool_result]&.call(result)
196
+ content = result.is_a?(Content) ? result : result.to_s
197
+ message = add_message role: :tool, content:, tool_call_id: tool_call.id
198
+ @on[:end_message]&.call(message)
199
+
200
+ halt_result = result if result.is_a?(Tool::Halt)
201
+ end
202
+
203
+ halt_result || complete(&)
204
+ end
205
+
206
+ def execute_tool(tool_call)
207
+ tool = tools[tool_call.name.to_sym]
208
+ args = tool_call.arguments
209
+ tool.call(args)
210
+ end
211
+ end
212
+ end