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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +175 -0
- data/lib/generators/ruby_llm/chat_ui/chat_ui_generator.rb +187 -0
- data/lib/generators/ruby_llm/chat_ui/templates/controllers/chats_controller.rb.tt +39 -0
- data/lib/generators/ruby_llm/chat_ui/templates/controllers/messages_controller.rb.tt +24 -0
- data/lib/generators/ruby_llm/chat_ui/templates/controllers/models_controller.rb.tt +14 -0
- data/lib/generators/ruby_llm/chat_ui/templates/jobs/chat_response_job.rb.tt +12 -0
- data/lib/generators/ruby_llm/chat_ui/templates/views/chats/_chat.html.erb.tt +16 -0
- data/lib/generators/ruby_llm/chat_ui/templates/views/chats/_form.html.erb.tt +29 -0
- data/lib/generators/ruby_llm/chat_ui/templates/views/chats/index.html.erb.tt +16 -0
- data/lib/generators/ruby_llm/chat_ui/templates/views/chats/new.html.erb.tt +11 -0
- data/lib/generators/ruby_llm/chat_ui/templates/views/chats/show.html.erb.tt +23 -0
- data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_content.html.erb.tt +1 -0
- data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_form.html.erb.tt +21 -0
- data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_message.html.erb.tt +13 -0
- data/lib/generators/ruby_llm/chat_ui/templates/views/messages/_tool_calls.html.erb.tt +7 -0
- data/lib/generators/ruby_llm/chat_ui/templates/views/messages/create.turbo_stream.erb.tt +9 -0
- data/lib/generators/ruby_llm/chat_ui/templates/views/models/_model.html.erb.tt +16 -0
- data/lib/generators/ruby_llm/chat_ui/templates/views/models/index.html.erb.tt +28 -0
- data/lib/generators/ruby_llm/chat_ui/templates/views/models/show.html.erb.tt +18 -0
- data/lib/generators/ruby_llm/generator_helpers.rb +194 -0
- data/lib/generators/ruby_llm/install/install_generator.rb +106 -0
- data/lib/generators/ruby_llm/install/templates/add_references_to_chats_tool_calls_and_messages_migration.rb.tt +9 -0
- data/lib/generators/ruby_llm/install/templates/chat_model.rb.tt +3 -0
- data/lib/generators/ruby_llm/install/templates/create_chats_migration.rb.tt +7 -0
- data/lib/generators/ruby_llm/install/templates/create_messages_migration.rb.tt +16 -0
- data/lib/generators/ruby_llm/install/templates/create_models_migration.rb.tt +45 -0
- data/lib/generators/ruby_llm/install/templates/create_tool_calls_migration.rb.tt +20 -0
- data/lib/generators/ruby_llm/install/templates/initializer.rb.tt +12 -0
- data/lib/generators/ruby_llm/install/templates/message_model.rb.tt +4 -0
- data/lib/generators/ruby_llm/install/templates/model_model.rb.tt +3 -0
- data/lib/generators/ruby_llm/install/templates/tool_call_model.rb.tt +3 -0
- data/lib/generators/ruby_llm/upgrade_to_v1_7/templates/migration.rb.tt +145 -0
- data/lib/generators/ruby_llm/upgrade_to_v1_7/upgrade_to_v1_7_generator.rb +124 -0
- data/lib/generators/ruby_llm/upgrade_to_v1_9/templates/add_v1_9_message_columns.rb.tt +15 -0
- data/lib/generators/ruby_llm/upgrade_to_v1_9/upgrade_to_v1_9_generator.rb +49 -0
- data/lib/ruby_llm/active_record/acts_as.rb +174 -0
- data/lib/ruby_llm/active_record/acts_as_legacy.rb +384 -0
- data/lib/ruby_llm/active_record/chat_methods.rb +350 -0
- data/lib/ruby_llm/active_record/message_methods.rb +81 -0
- data/lib/ruby_llm/active_record/model_methods.rb +84 -0
- data/lib/ruby_llm/aliases.json +295 -0
- data/lib/ruby_llm/aliases.rb +38 -0
- data/lib/ruby_llm/attachment.rb +220 -0
- data/lib/ruby_llm/chat.rb +816 -0
- data/lib/ruby_llm/chunk.rb +6 -0
- data/lib/ruby_llm/configuration.rb +78 -0
- data/lib/ruby_llm/connection.rb +126 -0
- data/lib/ruby_llm/content.rb +73 -0
- data/lib/ruby_llm/context.rb +29 -0
- data/lib/ruby_llm/embedding.rb +29 -0
- data/lib/ruby_llm/error.rb +84 -0
- data/lib/ruby_llm/image.rb +49 -0
- data/lib/ruby_llm/message.rb +86 -0
- data/lib/ruby_llm/mime_type.rb +71 -0
- data/lib/ruby_llm/model/info.rb +111 -0
- data/lib/ruby_llm/model/modalities.rb +22 -0
- data/lib/ruby_llm/model/pricing.rb +48 -0
- data/lib/ruby_llm/model/pricing_category.rb +46 -0
- data/lib/ruby_llm/model/pricing_tier.rb +33 -0
- data/lib/ruby_llm/model.rb +7 -0
- data/lib/ruby_llm/models.json +33198 -0
- data/lib/ruby_llm/models.rb +231 -0
- data/lib/ruby_llm/models_schema.json +168 -0
- data/lib/ruby_llm/moderation.rb +56 -0
- data/lib/ruby_llm/provider.rb +243 -0
- data/lib/ruby_llm/providers/anthropic/capabilities.rb +134 -0
- data/lib/ruby_llm/providers/anthropic/chat.rb +125 -0
- data/lib/ruby_llm/providers/anthropic/content.rb +44 -0
- data/lib/ruby_llm/providers/anthropic/embeddings.rb +20 -0
- data/lib/ruby_llm/providers/anthropic/media.rb +92 -0
- data/lib/ruby_llm/providers/anthropic/models.rb +63 -0
- data/lib/ruby_llm/providers/anthropic/streaming.rb +45 -0
- data/lib/ruby_llm/providers/anthropic/tools.rb +109 -0
- data/lib/ruby_llm/providers/anthropic.rb +36 -0
- data/lib/ruby_llm/providers/bedrock/capabilities.rb +167 -0
- data/lib/ruby_llm/providers/bedrock/chat.rb +63 -0
- data/lib/ruby_llm/providers/bedrock/media.rb +61 -0
- data/lib/ruby_llm/providers/bedrock/models.rb +98 -0
- data/lib/ruby_llm/providers/bedrock/signing.rb +831 -0
- data/lib/ruby_llm/providers/bedrock/streaming/base.rb +51 -0
- data/lib/ruby_llm/providers/bedrock/streaming/content_extraction.rb +71 -0
- data/lib/ruby_llm/providers/bedrock/streaming/message_processing.rb +67 -0
- data/lib/ruby_llm/providers/bedrock/streaming/payload_processing.rb +80 -0
- data/lib/ruby_llm/providers/bedrock/streaming/prelude_handling.rb +78 -0
- data/lib/ruby_llm/providers/bedrock/streaming.rb +18 -0
- data/lib/ruby_llm/providers/bedrock.rb +82 -0
- data/lib/ruby_llm/providers/deepseek/capabilities.rb +130 -0
- data/lib/ruby_llm/providers/deepseek/chat.rb +16 -0
- data/lib/ruby_llm/providers/deepseek.rb +30 -0
- data/lib/ruby_llm/providers/gemini/capabilities.rb +281 -0
- data/lib/ruby_llm/providers/gemini/chat.rb +454 -0
- data/lib/ruby_llm/providers/gemini/embeddings.rb +37 -0
- data/lib/ruby_llm/providers/gemini/images.rb +47 -0
- data/lib/ruby_llm/providers/gemini/media.rb +112 -0
- data/lib/ruby_llm/providers/gemini/models.rb +40 -0
- data/lib/ruby_llm/providers/gemini/streaming.rb +61 -0
- data/lib/ruby_llm/providers/gemini/tools.rb +198 -0
- data/lib/ruby_llm/providers/gemini/transcription.rb +116 -0
- data/lib/ruby_llm/providers/gemini.rb +37 -0
- data/lib/ruby_llm/providers/gpustack/chat.rb +27 -0
- data/lib/ruby_llm/providers/gpustack/media.rb +46 -0
- data/lib/ruby_llm/providers/gpustack/models.rb +90 -0
- data/lib/ruby_llm/providers/gpustack.rb +34 -0
- data/lib/ruby_llm/providers/mistral/capabilities.rb +155 -0
- data/lib/ruby_llm/providers/mistral/chat.rb +24 -0
- data/lib/ruby_llm/providers/mistral/embeddings.rb +33 -0
- data/lib/ruby_llm/providers/mistral/models.rb +48 -0
- data/lib/ruby_llm/providers/mistral.rb +32 -0
- data/lib/ruby_llm/providers/ollama/chat.rb +27 -0
- data/lib/ruby_llm/providers/ollama/media.rb +46 -0
- data/lib/ruby_llm/providers/ollama/models.rb +36 -0
- data/lib/ruby_llm/providers/ollama.rb +30 -0
- data/lib/ruby_llm/providers/openai/capabilities.rb +299 -0
- data/lib/ruby_llm/providers/openai/chat.rb +88 -0
- data/lib/ruby_llm/providers/openai/embeddings.rb +33 -0
- data/lib/ruby_llm/providers/openai/images.rb +38 -0
- data/lib/ruby_llm/providers/openai/media.rb +81 -0
- data/lib/ruby_llm/providers/openai/models.rb +39 -0
- data/lib/ruby_llm/providers/openai/moderation.rb +34 -0
- data/lib/ruby_llm/providers/openai/streaming.rb +46 -0
- data/lib/ruby_llm/providers/openai/tools.rb +98 -0
- data/lib/ruby_llm/providers/openai/transcription.rb +70 -0
- data/lib/ruby_llm/providers/openai.rb +44 -0
- data/lib/ruby_llm/providers/openai_responses.rb +395 -0
- data/lib/ruby_llm/providers/openrouter/models.rb +73 -0
- data/lib/ruby_llm/providers/openrouter.rb +26 -0
- data/lib/ruby_llm/providers/perplexity/capabilities.rb +137 -0
- data/lib/ruby_llm/providers/perplexity/chat.rb +16 -0
- data/lib/ruby_llm/providers/perplexity/models.rb +42 -0
- data/lib/ruby_llm/providers/perplexity.rb +48 -0
- data/lib/ruby_llm/providers/vertexai/chat.rb +14 -0
- data/lib/ruby_llm/providers/vertexai/embeddings.rb +32 -0
- data/lib/ruby_llm/providers/vertexai/models.rb +130 -0
- data/lib/ruby_llm/providers/vertexai/streaming.rb +14 -0
- data/lib/ruby_llm/providers/vertexai/transcription.rb +16 -0
- data/lib/ruby_llm/providers/vertexai.rb +55 -0
- data/lib/ruby_llm/railtie.rb +35 -0
- data/lib/ruby_llm/responses_session.rb +77 -0
- data/lib/ruby_llm/stream_accumulator.rb +101 -0
- data/lib/ruby_llm/streaming.rb +153 -0
- data/lib/ruby_llm/tool.rb +209 -0
- data/lib/ruby_llm/tool_call.rb +22 -0
- data/lib/ruby_llm/tool_executors.rb +125 -0
- data/lib/ruby_llm/transcription.rb +35 -0
- data/lib/ruby_llm/utils.rb +91 -0
- data/lib/ruby_llm/version.rb +5 -0
- data/lib/ruby_llm.rb +140 -0
- data/lib/tasks/models.rake +525 -0
- data/lib/tasks/release.rake +67 -0
- data/lib/tasks/ruby_llm.rake +15 -0
- data/lib/tasks/vcr.rake +92 -0
- 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
|