ruby_llm_swarm 1.9.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 (154) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +175 -0
  4. data/lib/generators/ruby_llm/chat_ui/chat_ui_generator.rb +187 -0
  5. data/lib/generators/ruby_llm/chat_ui/templates/controllers/chats_controller.rb.tt +39 -0
  6. data/lib/generators/ruby_llm/chat_ui/templates/controllers/messages_controller.rb.tt +24 -0
  7. data/lib/generators/ruby_llm/chat_ui/templates/controllers/models_controller.rb.tt +14 -0
  8. data/lib/generators/ruby_llm/chat_ui/templates/jobs/chat_response_job.rb.tt +12 -0
  9. data/lib/generators/ruby_llm/chat_ui/templates/views/chats/_chat.html.erb.tt +16 -0
  10. data/lib/generators/ruby_llm/chat_ui/templates/views/chats/_form.html.erb.tt +29 -0
  11. data/lib/generators/ruby_llm/chat_ui/templates/views/chats/index.html.erb.tt +16 -0
  12. data/lib/generators/ruby_llm/chat_ui/templates/views/chats/new.html.erb.tt +11 -0
  13. data/lib/generators/ruby_llm/chat_ui/templates/views/chats/show.html.erb.tt +23 -0
  14. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_content.html.erb.tt +1 -0
  15. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_form.html.erb.tt +21 -0
  16. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_message.html.erb.tt +13 -0
  17. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_tool_calls.html.erb.tt +7 -0
  18. data/lib/generators/ruby_llm/chat_ui/templates/views/messages/create.turbo_stream.erb.tt +9 -0
  19. data/lib/generators/ruby_llm/chat_ui/templates/views/models/_model.html.erb.tt +16 -0
  20. data/lib/generators/ruby_llm/chat_ui/templates/views/models/index.html.erb.tt +28 -0
  21. data/lib/generators/ruby_llm/chat_ui/templates/views/models/show.html.erb.tt +18 -0
  22. data/lib/generators/ruby_llm/generator_helpers.rb +194 -0
  23. data/lib/generators/ruby_llm/install/install_generator.rb +106 -0
  24. data/lib/generators/ruby_llm/install/templates/add_references_to_chats_tool_calls_and_messages_migration.rb.tt +9 -0
  25. data/lib/generators/ruby_llm/install/templates/chat_model.rb.tt +3 -0
  26. data/lib/generators/ruby_llm/install/templates/create_chats_migration.rb.tt +7 -0
  27. data/lib/generators/ruby_llm/install/templates/create_messages_migration.rb.tt +16 -0
  28. data/lib/generators/ruby_llm/install/templates/create_models_migration.rb.tt +45 -0
  29. data/lib/generators/ruby_llm/install/templates/create_tool_calls_migration.rb.tt +20 -0
  30. data/lib/generators/ruby_llm/install/templates/initializer.rb.tt +12 -0
  31. data/lib/generators/ruby_llm/install/templates/message_model.rb.tt +4 -0
  32. data/lib/generators/ruby_llm/install/templates/model_model.rb.tt +3 -0
  33. data/lib/generators/ruby_llm/install/templates/tool_call_model.rb.tt +3 -0
  34. data/lib/generators/ruby_llm/upgrade_to_v1_7/templates/migration.rb.tt +145 -0
  35. data/lib/generators/ruby_llm/upgrade_to_v1_7/upgrade_to_v1_7_generator.rb +124 -0
  36. data/lib/generators/ruby_llm/upgrade_to_v1_9/templates/add_v1_9_message_columns.rb.tt +15 -0
  37. data/lib/generators/ruby_llm/upgrade_to_v1_9/upgrade_to_v1_9_generator.rb +49 -0
  38. data/lib/ruby_llm/active_record/acts_as.rb +174 -0
  39. data/lib/ruby_llm/active_record/acts_as_legacy.rb +384 -0
  40. data/lib/ruby_llm/active_record/chat_methods.rb +350 -0
  41. data/lib/ruby_llm/active_record/message_methods.rb +81 -0
  42. data/lib/ruby_llm/active_record/model_methods.rb +84 -0
  43. data/lib/ruby_llm/aliases.json +295 -0
  44. data/lib/ruby_llm/aliases.rb +38 -0
  45. data/lib/ruby_llm/attachment.rb +220 -0
  46. data/lib/ruby_llm/chat.rb +816 -0
  47. data/lib/ruby_llm/chunk.rb +6 -0
  48. data/lib/ruby_llm/configuration.rb +78 -0
  49. data/lib/ruby_llm/connection.rb +126 -0
  50. data/lib/ruby_llm/content.rb +73 -0
  51. data/lib/ruby_llm/context.rb +29 -0
  52. data/lib/ruby_llm/embedding.rb +29 -0
  53. data/lib/ruby_llm/error.rb +84 -0
  54. data/lib/ruby_llm/image.rb +49 -0
  55. data/lib/ruby_llm/message.rb +86 -0
  56. data/lib/ruby_llm/mime_type.rb +71 -0
  57. data/lib/ruby_llm/model/info.rb +111 -0
  58. data/lib/ruby_llm/model/modalities.rb +22 -0
  59. data/lib/ruby_llm/model/pricing.rb +48 -0
  60. data/lib/ruby_llm/model/pricing_category.rb +46 -0
  61. data/lib/ruby_llm/model/pricing_tier.rb +33 -0
  62. data/lib/ruby_llm/model.rb +7 -0
  63. data/lib/ruby_llm/models.json +33198 -0
  64. data/lib/ruby_llm/models.rb +231 -0
  65. data/lib/ruby_llm/models_schema.json +168 -0
  66. data/lib/ruby_llm/moderation.rb +56 -0
  67. data/lib/ruby_llm/provider.rb +243 -0
  68. data/lib/ruby_llm/providers/anthropic/capabilities.rb +134 -0
  69. data/lib/ruby_llm/providers/anthropic/chat.rb +125 -0
  70. data/lib/ruby_llm/providers/anthropic/content.rb +44 -0
  71. data/lib/ruby_llm/providers/anthropic/embeddings.rb +20 -0
  72. data/lib/ruby_llm/providers/anthropic/media.rb +92 -0
  73. data/lib/ruby_llm/providers/anthropic/models.rb +63 -0
  74. data/lib/ruby_llm/providers/anthropic/streaming.rb +45 -0
  75. data/lib/ruby_llm/providers/anthropic/tools.rb +109 -0
  76. data/lib/ruby_llm/providers/anthropic.rb +36 -0
  77. data/lib/ruby_llm/providers/bedrock/capabilities.rb +167 -0
  78. data/lib/ruby_llm/providers/bedrock/chat.rb +63 -0
  79. data/lib/ruby_llm/providers/bedrock/media.rb +61 -0
  80. data/lib/ruby_llm/providers/bedrock/models.rb +98 -0
  81. data/lib/ruby_llm/providers/bedrock/signing.rb +831 -0
  82. data/lib/ruby_llm/providers/bedrock/streaming/base.rb +51 -0
  83. data/lib/ruby_llm/providers/bedrock/streaming/content_extraction.rb +71 -0
  84. data/lib/ruby_llm/providers/bedrock/streaming/message_processing.rb +67 -0
  85. data/lib/ruby_llm/providers/bedrock/streaming/payload_processing.rb +80 -0
  86. data/lib/ruby_llm/providers/bedrock/streaming/prelude_handling.rb +78 -0
  87. data/lib/ruby_llm/providers/bedrock/streaming.rb +18 -0
  88. data/lib/ruby_llm/providers/bedrock.rb +82 -0
  89. data/lib/ruby_llm/providers/deepseek/capabilities.rb +130 -0
  90. data/lib/ruby_llm/providers/deepseek/chat.rb +16 -0
  91. data/lib/ruby_llm/providers/deepseek.rb +30 -0
  92. data/lib/ruby_llm/providers/gemini/capabilities.rb +281 -0
  93. data/lib/ruby_llm/providers/gemini/chat.rb +454 -0
  94. data/lib/ruby_llm/providers/gemini/embeddings.rb +37 -0
  95. data/lib/ruby_llm/providers/gemini/images.rb +47 -0
  96. data/lib/ruby_llm/providers/gemini/media.rb +112 -0
  97. data/lib/ruby_llm/providers/gemini/models.rb +40 -0
  98. data/lib/ruby_llm/providers/gemini/streaming.rb +61 -0
  99. data/lib/ruby_llm/providers/gemini/tools.rb +198 -0
  100. data/lib/ruby_llm/providers/gemini/transcription.rb +116 -0
  101. data/lib/ruby_llm/providers/gemini.rb +37 -0
  102. data/lib/ruby_llm/providers/gpustack/chat.rb +27 -0
  103. data/lib/ruby_llm/providers/gpustack/media.rb +46 -0
  104. data/lib/ruby_llm/providers/gpustack/models.rb +90 -0
  105. data/lib/ruby_llm/providers/gpustack.rb +34 -0
  106. data/lib/ruby_llm/providers/mistral/capabilities.rb +155 -0
  107. data/lib/ruby_llm/providers/mistral/chat.rb +24 -0
  108. data/lib/ruby_llm/providers/mistral/embeddings.rb +33 -0
  109. data/lib/ruby_llm/providers/mistral/models.rb +48 -0
  110. data/lib/ruby_llm/providers/mistral.rb +32 -0
  111. data/lib/ruby_llm/providers/ollama/chat.rb +27 -0
  112. data/lib/ruby_llm/providers/ollama/media.rb +46 -0
  113. data/lib/ruby_llm/providers/ollama/models.rb +36 -0
  114. data/lib/ruby_llm/providers/ollama.rb +30 -0
  115. data/lib/ruby_llm/providers/openai/capabilities.rb +299 -0
  116. data/lib/ruby_llm/providers/openai/chat.rb +88 -0
  117. data/lib/ruby_llm/providers/openai/embeddings.rb +33 -0
  118. data/lib/ruby_llm/providers/openai/images.rb +38 -0
  119. data/lib/ruby_llm/providers/openai/media.rb +81 -0
  120. data/lib/ruby_llm/providers/openai/models.rb +39 -0
  121. data/lib/ruby_llm/providers/openai/moderation.rb +34 -0
  122. data/lib/ruby_llm/providers/openai/streaming.rb +46 -0
  123. data/lib/ruby_llm/providers/openai/tools.rb +98 -0
  124. data/lib/ruby_llm/providers/openai/transcription.rb +70 -0
  125. data/lib/ruby_llm/providers/openai.rb +44 -0
  126. data/lib/ruby_llm/providers/openai_responses.rb +395 -0
  127. data/lib/ruby_llm/providers/openrouter/models.rb +73 -0
  128. data/lib/ruby_llm/providers/openrouter.rb +26 -0
  129. data/lib/ruby_llm/providers/perplexity/capabilities.rb +137 -0
  130. data/lib/ruby_llm/providers/perplexity/chat.rb +16 -0
  131. data/lib/ruby_llm/providers/perplexity/models.rb +42 -0
  132. data/lib/ruby_llm/providers/perplexity.rb +48 -0
  133. data/lib/ruby_llm/providers/vertexai/chat.rb +14 -0
  134. data/lib/ruby_llm/providers/vertexai/embeddings.rb +32 -0
  135. data/lib/ruby_llm/providers/vertexai/models.rb +130 -0
  136. data/lib/ruby_llm/providers/vertexai/streaming.rb +14 -0
  137. data/lib/ruby_llm/providers/vertexai/transcription.rb +16 -0
  138. data/lib/ruby_llm/providers/vertexai.rb +55 -0
  139. data/lib/ruby_llm/railtie.rb +35 -0
  140. data/lib/ruby_llm/responses_session.rb +77 -0
  141. data/lib/ruby_llm/stream_accumulator.rb +101 -0
  142. data/lib/ruby_llm/streaming.rb +153 -0
  143. data/lib/ruby_llm/tool.rb +209 -0
  144. data/lib/ruby_llm/tool_call.rb +22 -0
  145. data/lib/ruby_llm/tool_executors.rb +125 -0
  146. data/lib/ruby_llm/transcription.rb +35 -0
  147. data/lib/ruby_llm/utils.rb +91 -0
  148. data/lib/ruby_llm/version.rb +5 -0
  149. data/lib/ruby_llm.rb +140 -0
  150. data/lib/tasks/models.rake +525 -0
  151. data/lib/tasks/release.rake +67 -0
  152. data/lib/tasks/ruby_llm.rake +15 -0
  153. data/lib/tasks/vcr.rake +92 -0
  154. metadata +346 -0
@@ -0,0 +1,299 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Providers
5
+ class OpenAI
6
+ # Determines capabilities and pricing for OpenAI models
7
+ module Capabilities
8
+ module_function
9
+
10
+ MODEL_PATTERNS = {
11
+ dall_e: /^dall-e/,
12
+ chatgpt4o: /^chatgpt-4o/,
13
+ gpt41: /^gpt-4\.1(?!-(?:mini|nano))/,
14
+ gpt41_mini: /^gpt-4\.1-mini/,
15
+ gpt41_nano: /^gpt-4\.1-nano/,
16
+ gpt4: /^gpt-4(?:-\d{6})?$/,
17
+ gpt4_turbo: /^gpt-4(?:\.5)?-(?:\d{6}-)?(preview|turbo)/,
18
+ gpt35_turbo: /^gpt-3\.5-turbo/,
19
+ gpt4o: /^gpt-4o(?!-(?:mini|audio|realtime|transcribe|tts|search))/,
20
+ gpt4o_audio: /^gpt-4o-(?:audio)/,
21
+ gpt4o_mini: /^gpt-4o-mini(?!-(?:audio|realtime|transcribe|tts|search))/,
22
+ gpt4o_mini_audio: /^gpt-4o-mini-audio/,
23
+ gpt4o_mini_realtime: /^gpt-4o-mini-realtime/,
24
+ gpt4o_mini_transcribe: /^gpt-4o-mini-transcribe/,
25
+ gpt4o_mini_tts: /^gpt-4o-mini-tts/,
26
+ gpt4o_realtime: /^gpt-4o-realtime/,
27
+ gpt4o_search: /^gpt-4o-search/,
28
+ gpt4o_transcribe: /^gpt-4o-transcribe/,
29
+ gpt5: /^gpt-5/,
30
+ gpt5_mini: /^gpt-5-mini/,
31
+ gpt5_nano: /^gpt-5-nano/,
32
+ o1: /^o1(?!-(?:mini|pro))/,
33
+ o1_mini: /^o1-mini/,
34
+ o1_pro: /^o1-pro/,
35
+ o3_mini: /^o3-mini/,
36
+ babbage: /^babbage/,
37
+ davinci: /^davinci/,
38
+ embedding3_large: /^text-embedding-3-large/,
39
+ embedding3_small: /^text-embedding-3-small/,
40
+ embedding_ada: /^text-embedding-ada/,
41
+ tts1: /^tts-1(?!-hd)/,
42
+ tts1_hd: /^tts-1-hd/,
43
+ whisper: /^whisper/,
44
+ moderation: /^(?:omni|text)-moderation/
45
+ }.freeze
46
+
47
+ def context_window_for(model_id)
48
+ case model_family(model_id)
49
+ when 'gpt41', 'gpt41_mini', 'gpt41_nano' then 1_047_576
50
+ when 'gpt5', 'gpt5_mini', 'gpt5_nano', 'chatgpt4o', 'gpt4_turbo', 'gpt4o', 'gpt4o_audio', 'gpt4o_mini',
51
+ 'gpt4o_mini_audio', 'gpt4o_mini_realtime', 'gpt4o_realtime',
52
+ 'gpt4o_search', 'gpt4o_transcribe', 'gpt4o_mini_search', 'o1_mini' then 128_000
53
+ when 'gpt4' then 8_192
54
+ when 'gpt4o_mini_transcribe' then 16_000
55
+ when 'o1', 'o1_pro', 'o3_mini' then 200_000
56
+ when 'gpt35_turbo' then 16_385
57
+ when 'gpt4o_mini_tts', 'tts1', 'tts1_hd', 'whisper', 'moderation',
58
+ 'embedding3_large', 'embedding3_small', 'embedding_ada' then nil
59
+ else 4_096
60
+ end
61
+ end
62
+
63
+ def max_tokens_for(model_id)
64
+ case model_family(model_id)
65
+ when 'gpt5', 'gpt5_mini', 'gpt5_nano' then 400_000
66
+ when 'gpt41', 'gpt41_mini', 'gpt41_nano' then 32_768
67
+ when 'chatgpt4o', 'gpt4o', 'gpt4o_mini', 'gpt4o_mini_search' then 16_384
68
+ when 'babbage', 'davinci' then 16_384 # rubocop:disable Lint/DuplicateBranch
69
+ when 'gpt4' then 8_192
70
+ when 'gpt35_turbo' then 4_096
71
+ when 'gpt4_turbo', 'gpt4o_realtime', 'gpt4o_mini_realtime' then 4_096 # rubocop:disable Lint/DuplicateBranch
72
+ when 'gpt4o_mini_transcribe' then 2_000
73
+ when 'o1', 'o1_pro', 'o3_mini' then 100_000
74
+ when 'o1_mini' then 65_536
75
+ when 'gpt4o_mini_tts', 'tts1', 'tts1_hd', 'whisper', 'moderation',
76
+ 'embedding3_large', 'embedding3_small', 'embedding_ada' then nil
77
+ else 16_384 # rubocop:disable Lint/DuplicateBranch
78
+ end
79
+ end
80
+
81
+ def supports_vision?(model_id)
82
+ case model_family(model_id)
83
+ when 'gpt5', 'gpt5_mini', 'gpt5_nano', 'gpt41', 'gpt41_mini', 'gpt41_nano', 'chatgpt4o', 'gpt4',
84
+ 'gpt4_turbo', 'gpt4o', 'gpt4o_mini', 'o1', 'o1_pro', 'moderation', 'gpt4o_search',
85
+ 'gpt4o_mini_search' then true
86
+ else false
87
+ end
88
+ end
89
+
90
+ def supports_functions?(model_id)
91
+ case model_family(model_id)
92
+ when 'gpt5', 'gpt5_mini', 'gpt5_nano', 'gpt41', 'gpt41_mini', 'gpt41_nano', 'gpt4', 'gpt4_turbo', 'gpt4o',
93
+ 'gpt4o_mini', 'o1', 'o1_pro', 'o3_mini' then true
94
+ when 'chatgpt4o', 'gpt35_turbo', 'o1_mini', 'gpt4o_mini_tts',
95
+ 'gpt4o_transcribe', 'gpt4o_search', 'gpt4o_mini_search' then false
96
+ else false # rubocop:disable Lint/DuplicateBranch
97
+ end
98
+ end
99
+
100
+ def supports_structured_output?(model_id)
101
+ case model_family(model_id)
102
+ when 'gpt5', 'gpt5_mini', 'gpt5_nano', 'gpt41', 'gpt41_mini', 'gpt41_nano', 'chatgpt4o', 'gpt4o',
103
+ 'gpt4o_mini', 'o1', 'o1_pro', 'o3_mini' then true
104
+ else false
105
+ end
106
+ end
107
+
108
+ def supports_json_mode?(model_id)
109
+ supports_structured_output?(model_id)
110
+ end
111
+
112
+ PRICES = {
113
+ gpt5: { input: 1.25, output: 10.0, cached_input: 0.125 },
114
+ gpt5_mini: { input: 0.25, output: 2.0, cached_input: 0.025 },
115
+ gpt5_nano: { input: 0.05, output: 0.4, cached_input: 0.005 },
116
+ gpt41: { input: 2.0, output: 8.0, cached_input: 0.5 },
117
+ gpt41_mini: { input: 0.4, output: 1.6, cached_input: 0.1 },
118
+ gpt41_nano: { input: 0.1, output: 0.4 },
119
+ chatgpt4o: { input: 5.0, output: 15.0 },
120
+ gpt4: { input: 10.0, output: 30.0 },
121
+ gpt4_turbo: { input: 10.0, output: 30.0 },
122
+ gpt45: { input: 75.0, output: 150.0 },
123
+ gpt35_turbo: { input: 0.5, output: 1.5 },
124
+ gpt4o: { input: 2.5, output: 10.0 },
125
+ gpt4o_audio: { input: 2.5, output: 10.0, audio_input: 40.0, audio_output: 80.0 },
126
+ gpt4o_mini: { input: 0.15, output: 0.6 },
127
+ gpt4o_mini_audio: { input: 0.15, output: 0.6, audio_input: 10.0, audio_output: 20.0 },
128
+ gpt4o_mini_realtime: { input: 0.6, output: 2.4 },
129
+ gpt4o_mini_transcribe: { input: 1.25, output: 5.0, audio_input: 3.0 },
130
+ gpt4o_mini_tts: { input: 0.6, output: 12.0 },
131
+ gpt4o_realtime: { input: 5.0, output: 20.0 },
132
+ gpt4o_search: { input: 2.5, output: 10.0 },
133
+ gpt4o_transcribe: { input: 2.5, output: 10.0, audio_input: 6.0 },
134
+ o1: { input: 15.0, output: 60.0 },
135
+ o1_mini: { input: 1.1, output: 4.4 },
136
+ o1_pro: { input: 150.0, output: 600.0 },
137
+ o3_mini: { input: 1.1, output: 4.4 },
138
+ babbage: { input: 0.4, output: 0.4 },
139
+ davinci: { input: 2.0, output: 2.0 },
140
+ embedding3_large: { price: 0.13 },
141
+ embedding3_small: { price: 0.02 },
142
+ embedding_ada: { price: 0.10 },
143
+ tts1: { price: 15.0 },
144
+ tts1_hd: { price: 30.0 },
145
+ whisper: { price: 0.006 },
146
+ moderation: { price: 0.0 }
147
+ }.freeze
148
+
149
+ def model_family(model_id)
150
+ MODEL_PATTERNS.each do |family, pattern|
151
+ return family.to_s if model_id.match?(pattern)
152
+ end
153
+ 'other'
154
+ end
155
+
156
+ def input_price_for(model_id)
157
+ family = model_family(model_id).to_sym
158
+ prices = PRICES.fetch(family, { input: default_input_price })
159
+ prices[:input] || prices[:price] || default_input_price
160
+ end
161
+
162
+ def cached_input_price_for(model_id)
163
+ family = model_family(model_id).to_sym
164
+ prices = PRICES.fetch(family, {})
165
+ prices[:cached_input]
166
+ end
167
+
168
+ def output_price_for(model_id)
169
+ family = model_family(model_id).to_sym
170
+ prices = PRICES.fetch(family, { output: default_output_price })
171
+ prices[:output] || prices[:price] || default_output_price
172
+ end
173
+
174
+ def model_type(model_id)
175
+ case model_family(model_id)
176
+ when /embedding/ then 'embedding'
177
+ when /^tts|whisper|gpt4o_(?:mini_)?(?:transcribe|tts)$/ then 'audio'
178
+ when 'moderation' then 'moderation'
179
+ when /dall/ then 'image'
180
+ else 'chat'
181
+ end
182
+ end
183
+
184
+ def default_input_price
185
+ 0.50
186
+ end
187
+
188
+ def default_output_price
189
+ 1.50
190
+ end
191
+
192
+ def format_display_name(model_id)
193
+ model_id.then { |id| humanize(id) }
194
+ .then { |name| apply_special_formatting(name) }
195
+ end
196
+
197
+ def humanize(id)
198
+ id.tr('-', ' ')
199
+ .split
200
+ .map(&:capitalize)
201
+ .join(' ')
202
+ end
203
+
204
+ def apply_special_formatting(name)
205
+ name
206
+ .gsub(/(\d{4}) (\d{2}) (\d{2})/, '\1\2\3')
207
+ .gsub(/^(?:Gpt|Chatgpt|Tts|Dall E) /) { |m| special_prefix_format(m.strip) }
208
+ .gsub(/^O([13]) /, 'O\1-')
209
+ .gsub(/^O[13] Mini/, '\0'.tr(' ', '-'))
210
+ .gsub(/\d\.\d /, '\0'.sub(' ', '-'))
211
+ .gsub(/4o (?=Mini|Preview|Turbo|Audio|Realtime|Transcribe|Tts)/, '4o-')
212
+ .gsub(/\bHd\b/, 'HD')
213
+ .gsub(/(?:Omni|Text) Moderation/, '\0'.tr(' ', '-'))
214
+ .gsub('Text Embedding', 'text-embedding-')
215
+ end
216
+
217
+ def special_prefix_format(prefix)
218
+ case prefix # rubocop:disable Style/HashLikeCase
219
+ when 'Gpt' then 'GPT-'
220
+ when 'Chatgpt' then 'ChatGPT-'
221
+ when 'Tts' then 'TTS-'
222
+ when 'Dall E' then 'DALL-E-'
223
+ end
224
+ end
225
+
226
+ def self.normalize_temperature(temperature, model_id)
227
+ if model_id.match?(/^(o\d|gpt-5)/)
228
+ RubyLLM.logger.debug "Model #{model_id} requires temperature=1.0, ignoring provided value"
229
+ 1.0
230
+ elsif model_id.match?(/-search/)
231
+ RubyLLM.logger.debug "Model #{model_id} does not accept temperature parameter, removing"
232
+ nil
233
+ else
234
+ temperature
235
+ end
236
+ end
237
+
238
+ def modalities_for(model_id)
239
+ modalities = {
240
+ input: ['text'],
241
+ output: ['text']
242
+ }
243
+
244
+ # Vision support
245
+ modalities[:input] << 'image' if supports_vision?(model_id)
246
+ modalities[:input] << 'audio' if model_id.match?(/whisper|audio|tts|transcribe/)
247
+ modalities[:input] << 'pdf' if supports_vision?(model_id)
248
+ modalities[:output] << 'audio' if model_id.match?(/tts|audio/)
249
+ modalities[:output] << 'image' if model_id.match?(/dall-e|image/)
250
+ modalities[:output] << 'embeddings' if model_id.match?(/embedding/)
251
+ modalities[:output] << 'moderation' if model_id.match?(/moderation/)
252
+
253
+ modalities
254
+ end
255
+
256
+ def capabilities_for(model_id) # rubocop:disable Metrics/PerceivedComplexity
257
+ capabilities = []
258
+
259
+ capabilities << 'streaming' unless model_id.match?(/moderation|embedding/)
260
+ capabilities << 'function_calling' if supports_functions?(model_id)
261
+ capabilities << 'structured_output' if supports_json_mode?(model_id)
262
+ capabilities << 'batch' if model_id.match?(/embedding|batch/)
263
+ capabilities << 'reasoning' if model_id.match?(/o\d|gpt-5|codex/)
264
+
265
+ if model_id.match?(/gpt-4-turbo|gpt-4o/)
266
+ capabilities << 'image_generation' if model_id.match?(/vision/)
267
+ capabilities << 'speech_generation' if model_id.match?(/audio/)
268
+ capabilities << 'transcription' if model_id.match?(/audio/)
269
+ end
270
+
271
+ capabilities
272
+ end
273
+
274
+ def pricing_for(model_id)
275
+ standard_pricing = {
276
+ input_per_million: input_price_for(model_id),
277
+ output_per_million: output_price_for(model_id)
278
+ }
279
+
280
+ if respond_to?(:cached_input_price_for)
281
+ cached_price = cached_input_price_for(model_id)
282
+ standard_pricing[:cached_input_per_million] = cached_price if cached_price
283
+ end
284
+
285
+ pricing = { text_tokens: { standard: standard_pricing } }
286
+
287
+ if model_id.match?(/embedding|batch/)
288
+ pricing[:text_tokens][:batch] = {
289
+ input_per_million: standard_pricing[:input_per_million] * 0.5,
290
+ output_per_million: standard_pricing[:output_per_million] * 0.5
291
+ }
292
+ end
293
+
294
+ pricing
295
+ end
296
+ end
297
+ end
298
+ end
299
+ end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Providers
5
+ class OpenAI
6
+ # Chat methods of the OpenAI API integration
7
+ module Chat
8
+ def completion_url
9
+ 'chat/completions'
10
+ end
11
+
12
+ module_function
13
+
14
+ def render_payload(messages, tools:, temperature:, model:, stream: false, schema: nil) # rubocop:disable Metrics/ParameterLists
15
+ payload = {
16
+ model: model.id,
17
+ messages: format_messages(messages),
18
+ stream: stream
19
+ }
20
+
21
+ payload[:temperature] = temperature unless temperature.nil?
22
+ payload[:tools] = tools.map { |_, tool| tool_for(tool) } if tools.any?
23
+
24
+ if schema
25
+ strict = schema[:strict] != false
26
+
27
+ payload[:response_format] = {
28
+ type: 'json_schema',
29
+ json_schema: {
30
+ name: 'response',
31
+ schema: schema,
32
+ strict: strict
33
+ }
34
+ }
35
+ end
36
+
37
+ payload[:stream_options] = { include_usage: true } if stream
38
+ payload
39
+ end
40
+
41
+ def parse_completion_response(response)
42
+ data = response.body
43
+ return if data.empty?
44
+
45
+ raise Error.new(response, data.dig('error', 'message')) if data.dig('error', 'message')
46
+
47
+ message_data = data.dig('choices', 0, 'message')
48
+ return unless message_data
49
+
50
+ usage = data['usage'] || {}
51
+ cached_tokens = usage.dig('prompt_tokens_details', 'cached_tokens')
52
+
53
+ Message.new(
54
+ role: :assistant,
55
+ content: message_data['content'],
56
+ tool_calls: parse_tool_calls(message_data['tool_calls']),
57
+ input_tokens: usage['prompt_tokens'],
58
+ output_tokens: usage['completion_tokens'],
59
+ cached_tokens: cached_tokens,
60
+ cache_creation_tokens: 0,
61
+ model_id: data['model'],
62
+ raw: response
63
+ )
64
+ end
65
+
66
+ def format_messages(messages)
67
+ messages.map do |msg|
68
+ {
69
+ role: format_role(msg.role),
70
+ content: Media.format_content(msg.content),
71
+ tool_calls: format_tool_calls(msg.tool_calls),
72
+ tool_call_id: msg.tool_call_id
73
+ }.compact
74
+ end
75
+ end
76
+
77
+ def format_role(role)
78
+ case role
79
+ when :system
80
+ @config.openai_use_system_role ? 'system' : 'developer'
81
+ else
82
+ role.to_s
83
+ end
84
+ end
85
+ end
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Providers
5
+ class OpenAI
6
+ # Embeddings methods of the OpenAI API integration
7
+ module Embeddings
8
+ module_function
9
+
10
+ def embedding_url(...)
11
+ 'embeddings'
12
+ end
13
+
14
+ def render_embedding_payload(text, model:, dimensions:)
15
+ {
16
+ model: model,
17
+ input: text,
18
+ dimensions: dimensions
19
+ }.compact
20
+ end
21
+
22
+ def parse_embedding_response(response, model:, text:)
23
+ data = response.body
24
+ input_tokens = data.dig('usage', 'prompt_tokens') || 0
25
+ vectors = data['data'].map { |d| d['embedding'] }
26
+ vectors = vectors.first if vectors.length == 1 && !text.is_a?(Array)
27
+
28
+ Embedding.new(vectors:, model:, input_tokens:)
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Providers
5
+ class OpenAI
6
+ # Image generation methods for the OpenAI API integration
7
+ module Images
8
+ module_function
9
+
10
+ def images_url
11
+ 'images/generations'
12
+ end
13
+
14
+ def render_image_payload(prompt, model:, size:)
15
+ {
16
+ model: model,
17
+ prompt: prompt,
18
+ n: 1,
19
+ size: size
20
+ }
21
+ end
22
+
23
+ def parse_image_response(response, model:)
24
+ data = response.body
25
+ image_data = data['data'].first
26
+
27
+ Image.new(
28
+ url: image_data['url'],
29
+ mime_type: 'image/png', # DALL-E typically returns PNGs
30
+ revised_prompt: image_data['revised_prompt'],
31
+ model_id: model,
32
+ data: image_data['b64_json']
33
+ )
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Providers
5
+ class OpenAI
6
+ # Handles formatting of media content (images, audio) for OpenAI APIs
7
+ module Media
8
+ module_function
9
+
10
+ def format_content(content) # rubocop:disable Metrics/PerceivedComplexity
11
+ return content.value if content.is_a?(RubyLLM::Content::Raw)
12
+ return content.to_json if content.is_a?(Hash) || content.is_a?(Array)
13
+ return content unless content.is_a?(Content)
14
+
15
+ parts = []
16
+ parts << format_text(content.text) if content.text
17
+
18
+ content.attachments.each do |attachment|
19
+ case attachment.type
20
+ when :image
21
+ parts << format_image(attachment)
22
+ when :pdf
23
+ parts << format_pdf(attachment)
24
+ when :audio
25
+ parts << format_audio(attachment)
26
+ when :text
27
+ parts << format_text_file(attachment)
28
+ else
29
+ raise UnsupportedAttachmentError, attachment.type
30
+ end
31
+ end
32
+
33
+ parts
34
+ end
35
+
36
+ def format_image(image)
37
+ {
38
+ type: 'image_url',
39
+ image_url: {
40
+ url: image.url? ? image.source : image.for_llm
41
+ }
42
+ }
43
+ end
44
+
45
+ def format_pdf(pdf)
46
+ {
47
+ type: 'file',
48
+ file: {
49
+ filename: pdf.filename,
50
+ file_data: pdf.for_llm
51
+ }
52
+ }
53
+ end
54
+
55
+ def format_text_file(text_file)
56
+ {
57
+ type: 'text',
58
+ text: text_file.for_llm
59
+ }
60
+ end
61
+
62
+ def format_audio(audio)
63
+ {
64
+ type: 'input_audio',
65
+ input_audio: {
66
+ data: audio.encoded,
67
+ format: audio.format
68
+ }
69
+ }
70
+ end
71
+
72
+ def format_text(text)
73
+ {
74
+ type: 'text',
75
+ text: text
76
+ }
77
+ end
78
+ end
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Providers
5
+ class OpenAI
6
+ # Models methods of the OpenAI API integration
7
+ module Models
8
+ module_function
9
+
10
+ def models_url
11
+ 'models'
12
+ end
13
+
14
+ def parse_list_models_response(response, slug, capabilities)
15
+ Array(response.body['data']).map do |model_data|
16
+ model_id = model_data['id']
17
+
18
+ Model::Info.new(
19
+ id: model_id,
20
+ name: capabilities.format_display_name(model_id),
21
+ provider: slug,
22
+ family: capabilities.model_family(model_id),
23
+ created_at: model_data['created'] ? Time.at(model_data['created']) : nil,
24
+ context_window: capabilities.context_window_for(model_id),
25
+ max_output_tokens: capabilities.max_tokens_for(model_id),
26
+ modalities: capabilities.modalities_for(model_id),
27
+ capabilities: capabilities.capabilities_for(model_id),
28
+ pricing: capabilities.pricing_for(model_id),
29
+ metadata: {
30
+ object: model_data['object'],
31
+ owned_by: model_data['owned_by']
32
+ }
33
+ )
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Providers
5
+ class OpenAI
6
+ # Moderation methods of the OpenAI API integration
7
+ module Moderation
8
+ module_function
9
+
10
+ def moderation_url
11
+ 'moderations'
12
+ end
13
+
14
+ def render_moderation_payload(input, model:)
15
+ {
16
+ model: model,
17
+ input: input
18
+ }
19
+ end
20
+
21
+ def parse_moderation_response(response, model:)
22
+ data = response.body
23
+ raise Error.new(response, data.dig('error', 'message')) if data.dig('error', 'message')
24
+
25
+ RubyLLM::Moderation.new(
26
+ id: data['id'],
27
+ model: model,
28
+ results: data['results'] || []
29
+ )
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Providers
5
+ class OpenAI
6
+ # Streaming methods of the OpenAI API integration
7
+ module Streaming
8
+ module_function
9
+
10
+ def stream_url
11
+ completion_url
12
+ end
13
+
14
+ def build_chunk(data)
15
+ usage = data['usage'] || {}
16
+ cached_tokens = usage.dig('prompt_tokens_details', 'cached_tokens')
17
+
18
+ Chunk.new(
19
+ role: :assistant,
20
+ model_id: data['model'],
21
+ content: data.dig('choices', 0, 'delta', 'content'),
22
+ tool_calls: parse_tool_calls(data.dig('choices', 0, 'delta', 'tool_calls'), parse_arguments: false),
23
+ input_tokens: usage['prompt_tokens'],
24
+ output_tokens: usage['completion_tokens'],
25
+ cached_tokens: cached_tokens,
26
+ cache_creation_tokens: 0
27
+ )
28
+ end
29
+
30
+ def parse_streaming_error(data)
31
+ error_data = JSON.parse(data)
32
+ return unless error_data['error']
33
+
34
+ case error_data.dig('error', 'type')
35
+ when 'server_error'
36
+ [500, error_data['error']['message']]
37
+ when 'rate_limit_exceeded', 'insufficient_quota'
38
+ [429, error_data['error']['message']]
39
+ else
40
+ [400, error_data['error']['message']]
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end