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,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Providers
5
+ class OpenAI
6
+ # Tools methods of the OpenAI API integration
7
+ module Tools
8
+ module_function
9
+
10
+ EMPTY_PARAMETERS_SCHEMA = {
11
+ 'type' => 'object',
12
+ 'properties' => {},
13
+ 'required' => [],
14
+ 'additionalProperties' => false,
15
+ 'strict' => true
16
+ }.freeze
17
+
18
+ def parameters_schema_for(tool)
19
+ tool.params_schema ||
20
+ schema_from_parameters(tool.parameters)
21
+ end
22
+
23
+ def schema_from_parameters(parameters)
24
+ schema_definition = RubyLLM::Tool::SchemaDefinition.from_parameters(parameters)
25
+ schema_definition&.json_schema || EMPTY_PARAMETERS_SCHEMA
26
+ end
27
+
28
+ def tool_for(tool)
29
+ parameters_schema = parameters_schema_for(tool)
30
+
31
+ definition = {
32
+ type: 'function',
33
+ function: {
34
+ name: tool.name,
35
+ description: tool.description,
36
+ parameters: parameters_schema
37
+ }
38
+ }
39
+
40
+ return definition if tool.provider_params.empty?
41
+
42
+ RubyLLM::Utils.deep_merge(definition, tool.provider_params)
43
+ end
44
+
45
+ def param_schema(param)
46
+ {
47
+ type: param.type,
48
+ description: param.description
49
+ }.compact
50
+ end
51
+
52
+ def format_tool_calls(tool_calls)
53
+ return nil unless tool_calls&.any?
54
+
55
+ tool_calls.map do |_, tc|
56
+ {
57
+ id: tc.id,
58
+ type: 'function',
59
+ function: {
60
+ name: tc.name,
61
+ arguments: JSON.generate(tc.arguments)
62
+ }
63
+ }
64
+ end
65
+ end
66
+
67
+ def parse_tool_call_arguments(tool_call)
68
+ arguments = tool_call.dig('function', 'arguments')
69
+
70
+ if arguments.nil? || arguments.empty?
71
+ {}
72
+ else
73
+ JSON.parse(arguments)
74
+ end
75
+ end
76
+
77
+ def parse_tool_calls(tool_calls, parse_arguments: true)
78
+ return nil unless tool_calls&.any?
79
+
80
+ tool_calls.to_h do |tc|
81
+ [
82
+ tc['id'],
83
+ ToolCall.new(
84
+ id: tc['id'],
85
+ name: tc.dig('function', 'name'),
86
+ arguments: if parse_arguments
87
+ parse_tool_call_arguments(tc)
88
+ else
89
+ tc.dig('function', 'arguments')
90
+ end
91
+ )
92
+ ]
93
+ end
94
+ end
95
+ end
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Providers
5
+ class OpenAI
6
+ # Audio transcription methods for the OpenAI API integration
7
+ module Transcription
8
+ module_function
9
+
10
+ def transcription_url
11
+ 'audio/transcriptions'
12
+ end
13
+
14
+ def render_transcription_payload(file_part, model:, language:, **options)
15
+ {
16
+ model: model,
17
+ file: file_part,
18
+ language: language,
19
+ chunking_strategy: (options[:chunking_strategy] || 'auto' if supports_chunking_strategy?(model, options)),
20
+ response_format: response_format_for(model, options),
21
+ prompt: options[:prompt],
22
+ temperature: options[:temperature],
23
+ timestamp_granularities: options[:timestamp_granularities],
24
+ known_speaker_names: options[:speaker_names],
25
+ known_speaker_references: encode_speaker_references(options[:speaker_references])
26
+ }.compact
27
+ end
28
+
29
+ def encode_speaker_references(references)
30
+ return nil unless references
31
+
32
+ references.map do |ref|
33
+ Attachment.new(ref).for_llm
34
+ end
35
+ end
36
+
37
+ def response_format_for(model, options)
38
+ return options[:response_format] if options.key?(:response_format)
39
+
40
+ 'diarized_json' if model.include?('diarize')
41
+ end
42
+
43
+ def supports_chunking_strategy?(model, options)
44
+ return false if model.start_with?('whisper')
45
+ return true if options.key?(:chunking_strategy)
46
+
47
+ model.include?('diarize')
48
+ end
49
+
50
+ def parse_transcription_response(response, model:)
51
+ data = response.body
52
+
53
+ return RubyLLM::Transcription.new(text: data, model: model) if data.is_a?(String)
54
+
55
+ usage = data['usage'] || {}
56
+
57
+ RubyLLM::Transcription.new(
58
+ text: data['text'],
59
+ model: model,
60
+ language: data['language'],
61
+ duration: data['duration'],
62
+ segments: data['segments'],
63
+ input_tokens: usage['input_tokens'] || usage['prompt_tokens'],
64
+ output_tokens: usage['output_tokens'] || usage['completion_tokens']
65
+ )
66
+ end
67
+ end
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Providers
5
+ # OpenAI API integration.
6
+ class OpenAI < Provider
7
+ include OpenAI::Chat
8
+ include OpenAI::Embeddings
9
+ include OpenAI::Models
10
+ include OpenAI::Moderation
11
+ include OpenAI::Streaming
12
+ include OpenAI::Tools
13
+ include OpenAI::Images
14
+ include OpenAI::Media
15
+ include OpenAI::Transcription
16
+
17
+ def api_base
18
+ @config.openai_api_base || 'https://api.openai.com/v1'
19
+ end
20
+
21
+ def headers
22
+ {
23
+ 'Authorization' => "Bearer #{@config.openai_api_key}",
24
+ 'OpenAI-Organization' => @config.openai_organization_id,
25
+ 'OpenAI-Project' => @config.openai_project_id
26
+ }.compact
27
+ end
28
+
29
+ def maybe_normalize_temperature(temperature, model)
30
+ OpenAI::Capabilities.normalize_temperature(temperature, model.id)
31
+ end
32
+
33
+ class << self
34
+ def capabilities
35
+ OpenAI::Capabilities
36
+ end
37
+
38
+ def configuration_requirements
39
+ %i[openai_api_key]
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,395 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Providers
5
+ # OpenAI Responses API provider.
6
+ # Uses v1/responses endpoint instead of v1/chat/completions.
7
+ # Inherits from OpenAI and overrides only what differs.
8
+ class OpenAIResponses < OpenAI
9
+ attr_reader :responses_session, :responses_config
10
+
11
+ def initialize(config, responses_session = nil, responses_config = {})
12
+ @responses_session = responses_session || ResponsesSession.new
13
+ @responses_config = {
14
+ stateful: false,
15
+ store: true,
16
+ truncation: :disabled,
17
+ include: []
18
+ }.merge(responses_config)
19
+
20
+ super(config)
21
+ end
22
+
23
+ # Override endpoint URL - no conditionals needed
24
+ def completion_url
25
+ 'responses'
26
+ end
27
+
28
+ # Override complete to handle response ID failures
29
+ def complete(messages, tools:, temperature:, model:, params: {}, headers: {}, schema: nil, &block) # rubocop:disable Metrics/ParameterLists
30
+ super
31
+ rescue BadRequestError => e
32
+ raise unless response_id_not_found_error?(e)
33
+
34
+ handle_response_id_failure
35
+ retry
36
+ end
37
+
38
+ # Override render_payload for Responses API format
39
+ def render_payload(messages, tools:, temperature:, model:, stream: false, schema: nil) # rubocop:disable Metrics/ParameterLists
40
+ system_msgs, other_msgs = partition_messages(messages)
41
+
42
+ payload = build_base_payload(model, stream)
43
+ add_instructions(payload, system_msgs)
44
+ add_input(payload, other_msgs)
45
+ add_temperature(payload, temperature)
46
+ add_tools(payload, tools)
47
+ add_schema(payload, schema)
48
+ add_optional_parameters(payload)
49
+ add_stream_options(payload, stream)
50
+
51
+ payload
52
+ end
53
+
54
+ # Override parse_completion_response for Responses API format
55
+ def parse_completion_response(response)
56
+ data = response.body
57
+ return if data.nil? || !data.is_a?(Hash) || data.empty?
58
+
59
+ # Check status first to handle failed responses appropriately
60
+ case data['status']
61
+ when 'completed'
62
+ parse_completed_response(data, response)
63
+ when 'failed'
64
+ raise ResponseFailedError.new(response, data.dig('error', 'message') || 'Response failed')
65
+ when 'in_progress', 'queued'
66
+ raise ResponseInProgressError.new(response, "Response still processing: #{data['id']}")
67
+ when 'cancelled'
68
+ raise ResponseCancelledError.new(response, "Response was cancelled: #{data['id']}")
69
+ when 'incomplete'
70
+ parse_incomplete_response(data, response)
71
+ else
72
+ # For responses without status, check for error
73
+ raise Error.new(response, data.dig('error', 'message')) if data.dig('error', 'message')
74
+
75
+ parse_completed_response(data, response)
76
+ end
77
+ end
78
+
79
+ # Override tool_for for flat format (not nested under 'function')
80
+ def tool_for(tool)
81
+ parameters_schema = parameters_schema_for(tool)
82
+
83
+ definition = {
84
+ type: 'function',
85
+ name: tool.name,
86
+ description: tool.description,
87
+ parameters: parameters_schema
88
+ }
89
+
90
+ return definition if tool.provider_params.empty?
91
+
92
+ RubyLLM::Utils.deep_merge(definition, tool.provider_params)
93
+ end
94
+
95
+ # Override build_chunk for Responses API streaming events
96
+ def build_chunk(data)
97
+ if responses_api_event?(data)
98
+ build_responses_chunk(data)
99
+ else
100
+ super
101
+ end
102
+ end
103
+
104
+ private
105
+
106
+ def stateful_mode?
107
+ @responses_config[:stateful] == true
108
+ end
109
+
110
+ def partition_messages(messages)
111
+ system_msgs = messages.select { |m| m.role == :system }
112
+ other_msgs = messages.reject { |m| m.role == :system }
113
+ [system_msgs, other_msgs]
114
+ end
115
+
116
+ def build_base_payload(model, stream)
117
+ {
118
+ model: model.id,
119
+ stream: stream,
120
+ store: @responses_config[:store]
121
+ }
122
+ end
123
+
124
+ def add_instructions(payload, system_msgs)
125
+ payload[:instructions] = format_instructions(system_msgs) if system_msgs.any?
126
+ end
127
+
128
+ def add_input(payload, other_msgs)
129
+ if stateful_mode? && @responses_session.valid?
130
+ payload[:previous_response_id] = @responses_session.response_id
131
+ payload[:input] = format_new_input_only(other_msgs)
132
+ else
133
+ payload[:input] = format_responses_input(other_msgs)
134
+ end
135
+ end
136
+
137
+ def add_temperature(payload, temperature)
138
+ payload[:temperature] = temperature unless temperature.nil?
139
+ end
140
+
141
+ def add_tools(payload, tools)
142
+ payload[:tools] = tools.map { |_, tool| tool_for(tool) } if tools.any?
143
+ end
144
+
145
+ def add_schema(payload, schema)
146
+ return unless schema
147
+
148
+ payload[:text] = {
149
+ format: {
150
+ type: 'json_schema',
151
+ name: 'response',
152
+ schema: schema,
153
+ strict: schema[:strict] != false
154
+ }
155
+ }
156
+ end
157
+
158
+ def add_stream_options(payload, stream)
159
+ payload[:stream_options] = { include_usage: true } if stream
160
+ end
161
+
162
+ def response_id_not_found_error?(error)
163
+ return false unless @responses_session.response_id
164
+
165
+ error.message.include?('not found')
166
+ end
167
+
168
+ def handle_response_id_failure
169
+ @responses_session.record_failure!
170
+
171
+ if @responses_session.disabled?
172
+ RubyLLM.logger.warn('Responses API: Disabling stateful mode after repeated failures')
173
+ else
174
+ RubyLLM.logger.debug('Responses API: Response ID not found, retrying fresh')
175
+ end
176
+ end
177
+
178
+ def format_instructions(system_messages)
179
+ system_messages.map { |m| m.content.to_s }.join("\n\n")
180
+ end
181
+
182
+ def format_responses_input(messages)
183
+ messages.filter_map do |msg|
184
+ case msg.role
185
+ when :user
186
+ {
187
+ type: 'message',
188
+ role: 'user',
189
+ content: format_input_content(msg.content)
190
+ }
191
+ when :assistant
192
+ next if msg.content.nil? || msg.content.to_s.strip.empty?
193
+
194
+ {
195
+ type: 'message',
196
+ role: 'assistant',
197
+ content: format_output_content(msg.content)
198
+ }
199
+ when :tool
200
+ {
201
+ type: 'function_call_output',
202
+ call_id: msg.tool_call_id,
203
+ output: msg.content.to_s
204
+ }
205
+ end
206
+ end
207
+ end
208
+
209
+ def format_new_input_only(messages)
210
+ formatted = []
211
+ last_assistant_idx = messages.rindex { |msg| msg.role == :assistant }
212
+
213
+ if last_assistant_idx
214
+ new_messages = messages[(last_assistant_idx + 1)..]
215
+ new_messages.each do |msg|
216
+ case msg.role
217
+ when :tool
218
+ formatted << {
219
+ type: 'function_call_output',
220
+ call_id: msg.tool_call_id,
221
+ output: msg.content.to_s
222
+ }
223
+ when :user
224
+ formatted << {
225
+ type: 'message',
226
+ role: 'user',
227
+ content: format_input_content(msg.content)
228
+ }
229
+ end
230
+ end
231
+ else
232
+ messages.each do |msg|
233
+ next unless msg.role == :user
234
+
235
+ formatted << {
236
+ type: 'message',
237
+ role: 'user',
238
+ content: format_input_content(msg.content)
239
+ }
240
+ end
241
+ end
242
+
243
+ formatted
244
+ end
245
+
246
+ def format_input_content(content)
247
+ case content
248
+ when String
249
+ [{ type: 'input_text', text: content }]
250
+ when Content
251
+ parts = []
252
+ parts << { type: 'input_text', text: content.text } if content.text && !content.text.empty?
253
+ content.attachments.each do |attachment|
254
+ parts << format_input_attachment(attachment)
255
+ end
256
+ parts
257
+ when Content::Raw
258
+ content.value
259
+ else
260
+ [{ type: 'input_text', text: content.to_s }]
261
+ end
262
+ end
263
+
264
+ def format_output_content(content)
265
+ if content.is_a?(String)
266
+ [{ type: 'output_text', text: content }]
267
+ elsif content.is_a?(Content)
268
+ [{ type: 'output_text', text: content.text || '' }]
269
+ else
270
+ [{ type: 'output_text', text: content.to_s }]
271
+ end
272
+ end
273
+
274
+ def format_input_attachment(attachment)
275
+ case attachment.type
276
+ when :image
277
+ if attachment.url?
278
+ { type: 'input_image', image_url: attachment.source.to_s }
279
+ else
280
+ { type: 'input_image', image_url: attachment.for_llm }
281
+ end
282
+ when :file, :pdf
283
+ { type: 'input_file', file_data: attachment.encoded, filename: attachment.filename }
284
+ else
285
+ { type: 'input_text', text: "[Unsupported attachment: #{attachment.type}]" }
286
+ end
287
+ end
288
+
289
+ def add_optional_parameters(payload)
290
+ if @responses_config[:truncation] && @responses_config[:truncation] != :disabled
291
+ payload[:truncation] = @responses_config[:truncation].to_s
292
+ end
293
+
294
+ if @responses_config[:include] && !@responses_config[:include].empty?
295
+ payload[:include] = @responses_config[:include].map { |i| i.to_s.tr('_', '.') }
296
+ end
297
+
298
+ payload[:service_tier] = @responses_config[:service_tier].to_s if @responses_config[:service_tier]
299
+ payload[:max_tool_calls] = @responses_config[:max_tool_calls] if @responses_config[:max_tool_calls]
300
+ end
301
+
302
+ def parse_completed_response(data, response)
303
+ output = data['output'] || []
304
+ content_parts = []
305
+ tool_calls = {}
306
+
307
+ output.each do |item|
308
+ case item['type']
309
+ when 'message'
310
+ content_parts << extract_message_content(item)
311
+ when 'function_call'
312
+ tool_calls[item['call_id']] = ToolCall.new(
313
+ id: item['call_id'],
314
+ name: item['name'],
315
+ arguments: parse_tool_arguments(item['arguments'])
316
+ )
317
+ end
318
+ end
319
+
320
+ usage = data['usage'] || {}
321
+
322
+ Message.new(
323
+ role: :assistant,
324
+ content: content_parts.join("\n"),
325
+ tool_calls: tool_calls.empty? ? nil : tool_calls,
326
+ response_id: data['id'],
327
+ reasoning_summary: data.dig('reasoning', 'summary'),
328
+ reasoning_tokens: usage.dig('output_tokens_details', 'reasoning_tokens'),
329
+ input_tokens: usage['input_tokens'] || 0,
330
+ output_tokens: usage['output_tokens'] || 0,
331
+ cached_tokens: usage.dig('prompt_tokens_details', 'cached_tokens'),
332
+ cache_creation_tokens: 0,
333
+ model_id: data['model'],
334
+ raw: response
335
+ )
336
+ end
337
+
338
+ def parse_tool_arguments(arguments)
339
+ if arguments.nil? || arguments.empty?
340
+ {}
341
+ elsif arguments.is_a?(String)
342
+ JSON.parse(arguments)
343
+ else
344
+ arguments
345
+ end
346
+ rescue JSON::ParserError
347
+ {}
348
+ end
349
+
350
+ def parse_incomplete_response(data, response)
351
+ message = parse_completed_response(data, response)
352
+ RubyLLM.logger.warn("Responses API: Incomplete response: #{data['incomplete_details']}")
353
+ message
354
+ end
355
+
356
+ def extract_message_content(item)
357
+ return '' unless item['content'].is_a?(Array)
358
+
359
+ item['content'].filter_map do |content_item|
360
+ content_item['text'] if content_item['type'] == 'output_text'
361
+ end.join
362
+ end
363
+
364
+ def responses_api_event?(data)
365
+ data.is_a?(Hash) && data['type']&.start_with?('response.')
366
+ end
367
+
368
+ def build_responses_chunk(data)
369
+ case data['type']
370
+ when 'response.content_part.delta'
371
+ Chunk.new(
372
+ role: :assistant,
373
+ content: data.dig('delta', 'text') || '',
374
+ model_id: nil,
375
+ input_tokens: nil,
376
+ output_tokens: nil
377
+ )
378
+ when 'response.completed'
379
+ usage = data.dig('response', 'usage') || {}
380
+ Chunk.new(
381
+ role: :assistant,
382
+ content: nil,
383
+ model_id: data.dig('response', 'model'),
384
+ input_tokens: usage['input_tokens'],
385
+ output_tokens: usage['output_tokens'],
386
+ cached_tokens: usage.dig('prompt_tokens_details', 'cached_tokens'),
387
+ cache_creation_tokens: 0
388
+ )
389
+ else
390
+ Chunk.new(role: :assistant, content: nil, model_id: nil, input_tokens: nil, output_tokens: nil)
391
+ end
392
+ end
393
+ end
394
+ end
395
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Providers
5
+ class OpenRouter
6
+ # Models methods of the OpenRouter 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| # rubocop:disable Metrics/BlockLength
16
+ modalities = {
17
+ input: Array(model_data.dig('architecture', 'input_modalities')),
18
+ output: Array(model_data.dig('architecture', 'output_modalities'))
19
+ }
20
+
21
+ pricing = { text_tokens: { standard: {} } }
22
+
23
+ pricing_types = {
24
+ prompt: :input_per_million,
25
+ completion: :output_per_million,
26
+ input_cache_read: :cached_input_per_million,
27
+ internal_reasoning: :reasoning_output_per_million
28
+ }
29
+
30
+ pricing_types.each do |source_key, target_key|
31
+ value = model_data.dig('pricing', source_key.to_s).to_f
32
+ pricing[:text_tokens][:standard][target_key] = value * 1_000_000 if value.positive?
33
+ end
34
+
35
+ capabilities = supported_parameters_to_capabilities(model_data['supported_parameters'])
36
+
37
+ Model::Info.new(
38
+ id: model_data['id'],
39
+ name: model_data['name'],
40
+ provider: slug,
41
+ family: model_data['id'].split('/').first,
42
+ created_at: model_data['created'] ? Time.at(model_data['created']) : nil,
43
+ context_window: model_data['context_length'],
44
+ max_output_tokens: model_data.dig('top_provider', 'max_completion_tokens'),
45
+ modalities: modalities,
46
+ capabilities: capabilities,
47
+ pricing: pricing,
48
+ metadata: {
49
+ description: model_data['description'],
50
+ architecture: model_data['architecture'],
51
+ top_provider: model_data['top_provider'],
52
+ per_request_limits: model_data['per_request_limits'],
53
+ supported_parameters: model_data['supported_parameters']
54
+ }
55
+ )
56
+ end
57
+ end
58
+
59
+ def supported_parameters_to_capabilities(params)
60
+ return [] unless params
61
+
62
+ capabilities = []
63
+ capabilities << 'streaming'
64
+ capabilities << 'function_calling' if params.include?('tools') || params.include?('tool_choice')
65
+ capabilities << 'structured_output' if params.include?('response_format')
66
+ capabilities << 'batch' if params.include?('batch')
67
+ capabilities << 'predicted_outputs' if params.include?('logit_bias') && params.include?('top_k')
68
+ capabilities
69
+ end
70
+ end
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Providers
5
+ # OpenRouter API integration.
6
+ class OpenRouter < OpenAI
7
+ include OpenRouter::Models
8
+
9
+ def api_base
10
+ 'https://openrouter.ai/api/v1'
11
+ end
12
+
13
+ def headers
14
+ {
15
+ 'Authorization' => "Bearer #{@config.openrouter_api_key}"
16
+ }
17
+ end
18
+
19
+ class << self
20
+ def configuration_requirements
21
+ %i[openrouter_api_key]
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end