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,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
|