ruby_llm 2.0.0 → 2.1.0
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 +4 -4
- data/README.md +65 -8
- data/lib/generators/ruby_llm/generator_helpers.rb +68 -6
- data/lib/generators/ruby_llm/install/templates/create_messages_migration.rb.tt +1 -0
- data/lib/generators/ruby_llm/install/templates/create_ruby_llm_records_migration.rb.tt +36 -3
- data/lib/generators/ruby_llm/provider/scaffold.rb +32 -22
- data/lib/generators/ruby_llm/provider/templates/gem/ci.yml.erb +1 -5
- data/lib/generators/ruby_llm/provider/templates/gem/gemfile.erb +1 -1
- data/lib/generators/ruby_llm/provider/templates/gem/gemspec.erb +1 -1
- data/lib/generators/ruby_llm/provider/templates/gem/rubocop.yml.erb +1 -1
- data/lib/generators/ruby_llm/upgrade/templates/upgrade_ruby_llm_to_2_1.rb.tt +78 -0
- data/lib/generators/ruby_llm/upgrade/upgrade_generator.rb +6 -163
- data/lib/ruby_llm/accounting/usage.rb +86 -49
- data/lib/ruby_llm/active_record/acts_as.rb +3 -0
- data/lib/ruby_llm/active_record/attachment_helpers.rb +50 -25
- data/lib/ruby_llm/active_record/batch.rb +1 -1
- data/lib/ruby_llm/active_record/chat_methods.rb +136 -40
- data/lib/ruby_llm/active_record/mcp_credential.rb +41 -0
- data/lib/ruby_llm/active_record/message_methods.rb +32 -7
- data/lib/ruby_llm/active_record/model.rb +1 -1
- data/lib/ruby_llm/active_record/provider_file.rb +64 -0
- data/lib/ruby_llm/active_record/record.rb +19 -0
- data/lib/ruby_llm/active_record/tool_call.rb +1 -1
- data/lib/ruby_llm/active_record/usage.rb +53 -3
- data/lib/ruby_llm/agent.rb +198 -33
- data/lib/ruby_llm/aliases.json +66 -8
- data/lib/ruby_llm/attachment.rb +54 -10
- data/lib/ruby_llm/batch.rb +26 -3
- data/lib/ruby_llm/chat/tool_concurrency.rb +47 -23
- data/lib/ruby_llm/chat/tool_search.rb +26 -0
- data/lib/ruby_llm/chat.rb +488 -82
- data/lib/ruby_llm/choice.rb +38 -0
- data/lib/ruby_llm/configuration.rb +36 -11
- data/lib/ruby_llm/context.rb +45 -32
- data/lib/ruby_llm/cost.rb +42 -1
- data/lib/ruby_llm/embedding.rb +9 -2
- data/lib/ruby_llm/error.rb +34 -2
- data/lib/ruby_llm/evaluation/assertions.rb +40 -0
- data/lib/ruby_llm/evaluation/case.rb +52 -0
- data/lib/ruby_llm/evaluation/dataset.rb +65 -0
- data/lib/ruby_llm/evaluation/evaluator.rb +126 -0
- data/lib/ruby_llm/evaluation/evidence.rb +125 -0
- data/lib/ruby_llm/evaluation/minitest.rb +26 -0
- data/lib/ruby_llm/evaluation/report.rb +103 -0
- data/lib/ruby_llm/evaluation/result.rb +75 -0
- data/lib/ruby_llm/evaluation/reviewer.rb +20 -0
- data/lib/ruby_llm/evaluation/rspec.rb +23 -0
- data/lib/ruby_llm/evaluation/runner.rb +93 -0
- data/lib/ruby_llm/evaluation/trial.rb +101 -0
- data/lib/ruby_llm/evaluation.rb +289 -0
- data/lib/ruby_llm/image.rb +12 -4
- data/lib/ruby_llm/judge/builder.rb +51 -0
- data/lib/ruby_llm/judge/data.rb +43 -0
- data/lib/ruby_llm/judge/question.rb +101 -0
- data/lib/ruby_llm/judge.rb +179 -0
- data/lib/ruby_llm/judgment.rb +125 -0
- data/lib/ruby_llm/mcp/apps.rb +25 -0
- data/lib/ruby_llm/mcp/client.rb +213 -0
- data/lib/ruby_llm/mcp/collection.rb +56 -0
- data/lib/ruby_llm/mcp/content.rb +44 -0
- data/lib/ruby_llm/mcp/error.rb +27 -0
- data/lib/ruby_llm/mcp/http.rb +379 -0
- data/lib/ruby_llm/mcp/input_request.rb +118 -0
- data/lib/ruby_llm/mcp/input_required_error.rb +29 -0
- data/lib/ruby_llm/mcp/listener.rb +168 -0
- data/lib/ruby_llm/mcp/oauth/key.rb +78 -0
- data/lib/ruby_llm/mcp/oauth/memory_store.rb +28 -0
- data/lib/ruby_llm/mcp/oauth/proofs.rb +39 -0
- data/lib/ruby_llm/mcp/oauth.rb +653 -0
- data/lib/ruby_llm/mcp/param_headers.rb +83 -0
- data/lib/ruby_llm/mcp/prompt.rb +71 -0
- data/lib/ruby_llm/mcp/resource.rb +84 -0
- data/lib/ruby_llm/mcp/resource_template.rb +99 -0
- data/lib/ruby_llm/mcp/result.rb +76 -0
- data/lib/ruby_llm/mcp/session_expired.rb +10 -0
- data/lib/ruby_llm/mcp/stdio.rb +250 -0
- data/lib/ruby_llm/mcp/task.rb +151 -0
- data/lib/ruby_llm/mcp/tool.rb +130 -0
- data/lib/ruby_llm/mcp.rb +1101 -0
- data/lib/ruby_llm/message.rb +41 -6
- data/lib/ruby_llm/model.rb +2 -1
- data/lib/ruby_llm/models/aliases.rb +5 -2
- data/lib/ruby_llm/models/lookup.rb +80 -0
- data/lib/ruby_llm/models/registry.rb +2 -2
- data/lib/ruby_llm/models/schema.rb +1 -1
- data/lib/ruby_llm/models.json +23082 -10158
- data/lib/ruby_llm/models.rb +20 -69
- data/lib/ruby_llm/moderation.rb +7 -2
- data/lib/ruby_llm/ocr.rb +7 -2
- data/lib/ruby_llm/open_telemetry/attributes.rb +111 -0
- data/lib/ruby_llm/open_telemetry.rb +82 -0
- data/lib/ruby_llm/probability.rb +30 -0
- data/lib/ruby_llm/progress.rb +44 -0
- data/lib/ruby_llm/prompt/context.rb +55 -0
- data/lib/ruby_llm/prompt.rb +1 -1
- data/lib/ruby_llm/protocol/binary_streaming.rb +1 -1
- data/lib/ruby_llm/protocol/request_shapes/analysis.rb +190 -0
- data/lib/ruby_llm/protocol/request_shapes.rb +183 -0
- data/lib/ruby_llm/protocol/stored_uploads.rb +62 -0
- data/lib/ruby_llm/protocol/stream_accumulator.rb +9 -25
- data/lib/ruby_llm/protocol/streaming.rb +38 -31
- data/lib/ruby_llm/protocol.rb +192 -40
- data/lib/ruby_llm/protocols/anthropic/chat.rb +39 -32
- data/lib/ruby_llm/protocols/anthropic/media.rb +6 -2
- data/lib/ruby_llm/protocols/anthropic/models.rb +16 -1
- data/lib/ruby_llm/protocols/anthropic/request_shapes.rb +70 -0
- data/lib/ruby_llm/protocols/anthropic/streaming.rb +23 -9
- data/lib/ruby_llm/protocols/anthropic/tools.rb +43 -0
- data/lib/ruby_llm/protocols/anthropic.rb +20 -1
- data/lib/ruby_llm/protocols/bedrock/async_videos.rb +9 -17
- data/lib/ruby_llm/protocols/bedrock/guardrails.rb +8 -9
- data/lib/ruby_llm/protocols/bedrock/rerank.rb +1 -17
- data/lib/ruby_llm/protocols/chat_completions/chat.rb +5 -7
- data/lib/ruby_llm/protocols/chat_completions/media.rb +34 -8
- data/lib/ruby_llm/protocols/chat_completions/request_shapes.rb +121 -0
- data/lib/ruby_llm/protocols/chat_completions/streaming.rb +3 -0
- data/lib/ruby_llm/protocols/chat_completions.rb +1 -0
- data/lib/ruby_llm/protocols/cohere/batch_requests.rb +0 -5
- data/lib/ruby_llm/protocols/cohere/batches.rb +3 -7
- data/lib/ruby_llm/protocols/cohere/chat.rb +8 -0
- data/lib/ruby_llm/protocols/cohere/embeddings.rb +7 -4
- data/lib/ruby_llm/protocols/cohere/media.rb +6 -2
- data/lib/ruby_llm/protocols/cohere/ocr.rb +1 -1
- data/lib/ruby_llm/protocols/cohere/streaming.rb +1 -0
- data/lib/ruby_llm/protocols/cohere/tools.rb +5 -1
- data/lib/ruby_llm/protocols/cohere.rb +1 -0
- data/lib/ruby_llm/protocols/converse/chat.rb +22 -131
- data/lib/ruby_llm/protocols/converse/media.rb +16 -2
- data/lib/ruby_llm/protocols/converse/reasoning.rb +194 -0
- data/lib/ruby_llm/protocols/converse/request_shapes.rb +99 -0
- data/lib/ruby_llm/protocols/converse/streaming.rb +2 -2
- data/lib/ruby_llm/protocols/converse/thinking_stream.rb +2 -1
- data/lib/ruby_llm/protocols/converse.rb +3 -5
- data/lib/ruby_llm/protocols/deepseek/files.rb +2 -9
- data/lib/ruby_llm/protocols/elevenlabs/flows/images.rb +1 -7
- data/lib/ruby_llm/protocols/elevenlabs/flows/videos.rb +11 -17
- data/lib/ruby_llm/protocols/elevenlabs/streaming_transcription.rb +1 -4
- data/lib/ruby_llm/protocols/gemini/chat.rb +50 -21
- data/lib/ruby_llm/protocols/gemini/embeddings.rb +2 -2
- data/lib/ruby_llm/protocols/gemini/files.rb +1 -1
- data/lib/ruby_llm/protocols/gemini/images.rb +5 -1
- data/lib/ruby_llm/protocols/gemini/media.rb +15 -3
- data/lib/ruby_llm/protocols/gemini/request_shapes.rb +111 -0
- data/lib/ruby_llm/protocols/gemini/streaming.rb +2 -1
- data/lib/ruby_llm/protocols/gemini/tools.rb +17 -11
- data/lib/ruby_llm/protocols/gemini/transcription.rb +13 -0
- data/lib/ruby_llm/protocols/gemini/videos.rb +7 -2
- data/lib/ruby_llm/protocols/gemini.rb +1 -0
- data/lib/ruby_llm/protocols/gpustack/videos.rb +7 -9
- data/lib/ruby_llm/protocols/interactions/chat.rb +46 -18
- data/lib/ruby_llm/protocols/interactions/content.rb +9 -2
- data/lib/ruby_llm/protocols/interactions/request_shapes.rb +62 -0
- data/lib/ruby_llm/protocols/interactions/streaming.rb +15 -0
- data/lib/ruby_llm/protocols/interactions/tools.rb +28 -4
- data/lib/ruby_llm/protocols/interactions/transcription.rb +1 -11
- data/lib/ruby_llm/protocols/interactions.rb +1 -0
- data/lib/ruby_llm/protocols/invoke_model/cohere_embeddings.rb +2 -11
- data/lib/ruby_llm/protocols/invoke_model/embedding_batches.rb +2 -2
- data/lib/ruby_llm/protocols/invoke_model/stability_images.rb +2 -6
- data/lib/ruby_llm/protocols/invoke_model/titan_text_embeddings.rb +2 -8
- data/lib/ruby_llm/protocols/invoke_model.rb +1 -4
- data/lib/ruby_llm/protocols/mistral/content.rb +13 -0
- data/lib/ruby_llm/protocols/mistral/conversations/chat.rb +11 -1
- data/lib/ruby_llm/protocols/mistral/conversations/images.rb +5 -1
- data/lib/ruby_llm/protocols/mistral/conversations/request_shapes.rb +45 -0
- data/lib/ruby_llm/protocols/mistral/conversations.rb +1 -0
- data/lib/ruby_llm/protocols/mistral/multi_completion.rb +11 -1
- data/lib/ruby_llm/protocols/openai/decisions.rb +135 -0
- data/lib/ruby_llm/protocols/openai/files.rb +0 -7
- data/lib/ruby_llm/protocols/openrouter/batches.rb +13 -8
- data/lib/ruby_llm/protocols/openrouter/responses.rb +5 -1
- data/lib/ruby_llm/protocols/perplexity/agent.rb +116 -0
- data/lib/ruby_llm/protocols/perplexity/router.rb +2 -43
- data/lib/ruby_llm/protocols/responses/chat.rb +56 -22
- data/lib/ruby_llm/protocols/responses/media.rb +11 -7
- data/lib/ruby_llm/protocols/responses/request_shapes.rb +70 -0
- data/lib/ruby_llm/protocols/responses/streaming.rb +23 -7
- data/lib/ruby_llm/protocols/responses/token_counting.rb +2 -2
- data/lib/ruby_llm/protocols/responses/tools.rb +27 -0
- data/lib/ruby_llm/protocols/responses.rb +1 -0
- data/lib/ruby_llm/protocols/system_one/judgments.rb +42 -0
- data/lib/ruby_llm/protocols/system_one/models.rb +27 -0
- data/lib/ruby_llm/protocols/system_one/responses.rb +55 -0
- data/lib/ruby_llm/protocols/system_one.rb +27 -0
- data/lib/ruby_llm/protocols/vertexai/embedding_prediction/requests.rb +2 -2
- data/lib/ruby_llm/protocols/vertexai/files.rb +34 -6
- data/lib/ruby_llm/protocols/vertexai/ranking.rb +9 -30
- data/lib/ruby_llm/protocols/vertexai/research.rb +9 -41
- data/lib/ruby_llm/protocols/xai/streaming_transcription.rb +2 -5
- data/lib/ruby_llm/provider.rb +87 -15
- data/lib/ruby_llm/providers/anthropic.rb +23 -0
- data/lib/ruby_llm/providers/azure/chat.rb +4 -0
- data/lib/ruby_llm/providers/azure/images.rb +2 -2
- data/lib/ruby_llm/providers/azure/media.rb +8 -3
- data/lib/ruby_llm/providers/azure/videos.rb +7 -2
- data/lib/ruby_llm/providers/azure.rb +24 -4
- data/lib/ruby_llm/providers/bedrock/auth.rb +1 -1
- data/lib/ruby_llm/providers/bedrock/mantle/anthropic.rb +1 -1
- data/lib/ruby_llm/providers/bedrock/mantle/voxtral.rb +1 -3
- data/lib/ruby_llm/providers/bedrock/mantle.rb +5 -9
- data/lib/ruby_llm/providers/bedrock.rb +38 -6
- data/lib/ruby_llm/providers/deepseek/chat.rb +6 -1
- data/lib/ruby_llm/providers/deepseek/responses.rb +6 -0
- data/lib/ruby_llm/providers/deepseek.rb +4 -0
- data/lib/ruby_llm/providers/gemini.rb +4 -0
- data/lib/ruby_llm/providers/gpustack/media.rb +8 -5
- data/lib/ruby_llm/providers/hetzner/chat.rb +39 -0
- data/lib/ruby_llm/providers/hetzner.rb +41 -0
- data/lib/ruby_llm/providers/mistral/chat.rb +16 -12
- data/lib/ruby_llm/providers/mistral/media.rb +8 -4
- data/lib/ruby_llm/providers/mistral/ocr.rb +1 -5
- data/lib/ruby_llm/providers/ollama/judgments.rb +26 -0
- data/lib/ruby_llm/providers/ollama/media.rb +6 -2
- data/lib/ruby_llm/providers/ollama/models.rb +2 -0
- data/lib/ruby_llm/providers/ollama.rb +11 -0
- data/lib/ruby_llm/providers/openai/capabilities.rb +3 -1
- data/lib/ruby_llm/providers/openai.rb +14 -0
- data/lib/ruby_llm/providers/openrouter/chat.rb +20 -24
- data/lib/ruby_llm/providers/openrouter/embeddings.rb +2 -0
- data/lib/ruby_llm/providers/openrouter/media.rb +5 -1
- data/lib/ruby_llm/providers/openrouter/streaming.rb +4 -1
- data/lib/ruby_llm/providers/openrouter/videos.rb +10 -5
- data/lib/ruby_llm/providers/openrouter.rb +4 -0
- data/lib/ruby_llm/providers/perplexity/chat.rb +4 -0
- data/lib/ruby_llm/providers/perplexity/media.rb +5 -11
- data/lib/ruby_llm/providers/perplexity/models.rb +8 -4
- data/lib/ruby_llm/providers/perplexity.rb +11 -0
- data/lib/ruby_llm/providers/typesafe.rb +32 -0
- data/lib/ruby_llm/providers/vertexai/credentials.rb +63 -0
- data/lib/ruby_llm/providers/vertexai/gemini.rb +2 -2
- data/lib/ruby_llm/providers/vertexai/transcription.rb +1 -0
- data/lib/ruby_llm/providers/vertexai.rb +27 -28
- data/lib/ruby_llm/providers/xai/chat.rb +6 -0
- data/lib/ruby_llm/providers/xai/images.rb +9 -10
- data/lib/ruby_llm/providers/xai/responses.rb +10 -8
- data/lib/ruby_llm/providers/xai/videos.rb +11 -9
- data/lib/ruby_llm/providers/xai.rb +4 -0
- data/lib/ruby_llm/railtie.rb +11 -0
- data/lib/ruby_llm/request_shape/part.rb +117 -0
- data/lib/ruby_llm/request_shape/problem.rb +63 -0
- data/lib/ruby_llm/request_shape/tool_round.rb +64 -0
- data/lib/ruby_llm/request_shape/turn.rb +54 -0
- data/lib/ruby_llm/request_shape.rb +160 -0
- data/lib/ruby_llm/rerank.rb +9 -2
- data/lib/ruby_llm/research_job.rb +21 -5
- data/lib/ruby_llm/score.rb +42 -0
- data/lib/ruby_llm/search_results.rb +2 -2
- data/lib/ruby_llm/server_tool_call.rb +10 -2
- data/lib/ruby_llm/speech.rb +8 -1
- data/lib/ruby_llm/support/cancellation.rb +39 -0
- data/lib/ruby_llm/support/inspectable.rb +3 -2
- data/lib/ruby_llm/support/instrumentation.rb +38 -1
- data/lib/ruby_llm/support/process_cache.rb +60 -0
- data/lib/ruby_llm/support/progress_reporter.rb +33 -0
- data/lib/ruby_llm/thinking.rb +2 -1
- data/lib/ruby_llm/tokens.rb +57 -14
- data/lib/ruby_llm/tool.rb +50 -3
- data/lib/ruby_llm/tool_call.rb +17 -2
- data/lib/ruby_llm/transcription.rb +10 -3
- data/lib/ruby_llm/transport/connection.rb +127 -44
- data/lib/ruby_llm/transport/error_middleware.rb +90 -24
- data/lib/ruby_llm/transport/event_stream_parser.rb +121 -0
- data/lib/ruby_llm/transport/json_response.rb +33 -0
- data/lib/ruby_llm/transport/signing_middleware.rb +29 -0
- data/lib/ruby_llm/version.rb +1 -1
- data/lib/ruby_llm/video.rb +5 -3
- data/lib/ruby_llm/video_job.rb +48 -5
- data/lib/ruby_llm.rb +106 -5
- data/lib/tasks/ruby_llm/evaluations.rb +43 -0
- data/lib/tasks/ruby_llm.rake +6 -9
- data/skills/rubyllm/SKILL.md +33 -12
- metadata +95 -37
- data/lib/generators/ruby_llm/upgrade/legacy_content_sql.rb +0 -34
- data/lib/generators/ruby_llm/upgrade/online_copy_migration/data.rb +0 -341
- data/lib/generators/ruby_llm/upgrade/online_copy_migration/journal.rb +0 -171
- data/lib/generators/ruby_llm/upgrade/online_copy_migration/verification.rb +0 -197
- data/lib/generators/ruby_llm/upgrade/online_copy_migration.rb +0 -174
- data/lib/generators/ruby_llm/upgrade/templates/backfill_v2_data.rb.tt +0 -468
- data/lib/generators/ruby_llm/upgrade/templates/cleanup_v2_upgrade.rb.tt +0 -101
- data/lib/generators/ruby_llm/upgrade/templates/finish_v2_upgrade.rb.tt +0 -247
- data/lib/generators/ruby_llm/upgrade/templates/prepare_v2_upgrade.rb.tt +0 -662
- data/lib/generators/ruby_llm/upgrade/templates/ruby_llm_upgrade.rb.tt +0 -251
- data/lib/generators/ruby_llm/upgrade/templates/upgrade_initializer.rb.tt +0 -13
- data/lib/generators/ruby_llm/upgrade/upgrade_migration.rb +0 -359
|
@@ -24,11 +24,6 @@ module RubyLLM
|
|
|
24
24
|
|
|
25
25
|
def render_batch_request(request, type:)
|
|
26
26
|
body = JSON.parse(JSON.generate(batch_payload(request, except: :model)))
|
|
27
|
-
if type == 'batch-embed-v2-input' && body['output_dimension']
|
|
28
|
-
raise ArgumentError,
|
|
29
|
-
'Cohere batch datasets currently reject dimensions; omit dimensions to use the model default'
|
|
30
|
-
end
|
|
31
|
-
|
|
32
27
|
render_batch_chat(body) if type == 'batch-chat-v2-input'
|
|
33
28
|
allowed = type == 'batch-chat-v2-input' ? CHAT_FIELDS : EMBEDDING_FIELDS
|
|
34
29
|
unsupported = body.keys - allowed
|
|
@@ -40,13 +40,7 @@ module RubyLLM
|
|
|
40
40
|
file = datasets.wait_for_validation(data.fetch('output_dataset_id'))
|
|
41
41
|
model = RubyLLM.models.find(data.fetch('model'), provider: @provider.slug, config: @config)
|
|
42
42
|
parser = self.class.new(@provider, model)
|
|
43
|
-
|
|
44
|
-
unless results.map(&:first).uniq.size == results.size
|
|
45
|
-
raise Error,
|
|
46
|
-
'Cohere returned duplicate batch request IDs'
|
|
47
|
-
end
|
|
48
|
-
|
|
49
|
-
results
|
|
43
|
+
datasets.records(file).map { |row| parse_batch_result(row, parser:, model: model.id) }
|
|
50
44
|
end
|
|
51
45
|
|
|
52
46
|
private
|
|
@@ -84,6 +78,8 @@ module RubyLLM
|
|
|
84
78
|
return [index, nil, batch_failure(custom_id, row['error'])] if !row['error'].to_s.empty? || !row['body']
|
|
85
79
|
|
|
86
80
|
body = row.fetch('body')
|
|
81
|
+
return [index, nil, batch_failure(custom_id, 'generation failed')] if body['finish_reason'] == 'ERROR'
|
|
82
|
+
|
|
87
83
|
result = if body['embeddings']
|
|
88
84
|
parser.send(:parse_embedding_response, Response.new(body), model:,
|
|
89
85
|
text: shape == 'array' ? [] : nil)
|
|
@@ -107,6 +107,8 @@ module RubyLLM
|
|
|
107
107
|
end
|
|
108
108
|
|
|
109
109
|
def parse_completion_body(data, raw:)
|
|
110
|
+
raise_failed_generation(nil, response: raw) if data['finish_reason'] == 'ERROR'
|
|
111
|
+
|
|
110
112
|
message_data = data['message'] || {}
|
|
111
113
|
blocks = Array(message_data['content'])
|
|
112
114
|
content, offsets = extract_text(blocks)
|
|
@@ -125,6 +127,12 @@ module RubyLLM
|
|
|
125
127
|
)
|
|
126
128
|
end
|
|
127
129
|
|
|
130
|
+
# Cohere answers a generation that failed on its side with an ERROR
|
|
131
|
+
# finish reason instead of an error status.
|
|
132
|
+
def raise_failed_generation(message, response: nil)
|
|
133
|
+
raise ServerError.new(message || 'The generation failed due to an internal error', response:)
|
|
134
|
+
end
|
|
135
|
+
|
|
128
136
|
# Cohere reports what the model processed under tokens and what it
|
|
129
137
|
# charges for under billed_units; the two differ because Cohere does
|
|
130
138
|
# not bill its own preamble.
|
|
@@ -37,11 +37,14 @@ module RubyLLM
|
|
|
37
37
|
end
|
|
38
38
|
|
|
39
39
|
def image_embedding_inputs(text, attachments) # :nodoc:
|
|
40
|
-
raise ArgumentError, 'Cohere Embed v3 accepts text or
|
|
41
|
-
raise ArgumentError, 'Cohere Embed v3 accepts one image per request' unless attachments.one?
|
|
42
|
-
raise UnsupportedAttachmentError, attachments.first.mime_type unless attachments.first.image?
|
|
40
|
+
raise ArgumentError, 'Cohere Embed v3 accepts text or images, not both' unless text.nil? || text == ''
|
|
43
41
|
|
|
44
|
-
|
|
42
|
+
images = attachments.map do |attachment|
|
|
43
|
+
raise UnsupportedAttachmentError, attachment.mime_type unless attachment.image?
|
|
44
|
+
|
|
45
|
+
"data:#{attachment.mime_type};base64,#{attachment.encoded}"
|
|
46
|
+
end
|
|
47
|
+
{ images: }
|
|
45
48
|
end
|
|
46
49
|
|
|
47
50
|
def parse_embedding_response(response, model:, text:)
|
|
@@ -11,6 +11,10 @@ module RubyLLM
|
|
|
11
11
|
|
|
12
12
|
module_function
|
|
13
13
|
|
|
14
|
+
def supported_attachment?(attachment)
|
|
15
|
+
%i[image text].include?(attachment.type)
|
|
16
|
+
end
|
|
17
|
+
|
|
14
18
|
# Cohere user messages accept only text and image_url blocks. Text
|
|
15
19
|
# attachments become citable documents when citations are on, so they
|
|
16
20
|
# are pulled out of the message and left out of the content blocks.
|
|
@@ -19,13 +23,13 @@ module RubyLLM
|
|
|
19
23
|
parts << format_text(content) if content
|
|
20
24
|
|
|
21
25
|
attachments.each do |attachment|
|
|
26
|
+
raise UnsupportedAttachmentError, attachment.mime_type unless supported_attachment?(attachment)
|
|
27
|
+
|
|
22
28
|
case attachment.type
|
|
23
29
|
when :image
|
|
24
30
|
parts << format_image(attachment)
|
|
25
31
|
when :text
|
|
26
32
|
parts << format_text(attachment.for_llm) unless citations
|
|
27
|
-
else
|
|
28
|
-
raise UnsupportedAttachmentError, attachment.mime_type
|
|
29
33
|
end
|
|
30
34
|
end
|
|
31
35
|
|
|
@@ -16,7 +16,7 @@ module RubyLLM
|
|
|
16
16
|
raise UnsupportedAttachmentError, attachment.mime_type unless attachment.image?
|
|
17
17
|
raise ArgumentError, 'Cohere Parse accepts one image; pages must be [0]' unless pages.nil? || pages == [0]
|
|
18
18
|
|
|
19
|
-
reference = attachment.
|
|
19
|
+
reference = attachment.url_or_data_uri
|
|
20
20
|
{
|
|
21
21
|
model: model,
|
|
22
22
|
document: { type: 'image_url', image_url: reference },
|
|
@@ -76,6 +76,10 @@ module RubyLLM
|
|
|
76
76
|
}
|
|
77
77
|
end
|
|
78
78
|
|
|
79
|
+
def supported_message_attachment?(message, attachment)
|
|
80
|
+
message.role == :tool ? attachment.text? : supported_attachment?(attachment)
|
|
81
|
+
end
|
|
82
|
+
|
|
79
83
|
# Search results become document content blocks, the shape Cohere
|
|
80
84
|
# cites tool output against. A tool that attaches files sends its
|
|
81
85
|
# text and each attachment as blocks, so the model reads what the
|
|
@@ -86,7 +90,7 @@ module RubyLLM
|
|
|
86
90
|
return msg.content.to_s if msg.attachments.empty?
|
|
87
91
|
|
|
88
92
|
[Media.format_text(msg.content.to_s)] + msg.attachments.map do |attachment|
|
|
89
|
-
raise UnsupportedAttachmentError, attachment.mime_type unless attachment
|
|
93
|
+
raise UnsupportedAttachmentError, attachment.mime_type unless supported_message_attachment?(msg, attachment)
|
|
90
94
|
|
|
91
95
|
Media.format_text(attachment.for_llm)
|
|
92
96
|
end
|
|
@@ -16,8 +16,6 @@ module RubyLLM
|
|
|
16
16
|
BEDROCK_INLINE_DOCUMENT_LIMIT = 4_500_000
|
|
17
17
|
PROMPT_CACHE_OPTIONS = %i[ttl].freeze
|
|
18
18
|
COUNT_TOKENS_KEYS = %i[messages system toolConfig additionalModelRequestFields].freeze
|
|
19
|
-
RANGED_EFFORTS = %w[low medium high].freeze
|
|
20
|
-
MINIMUM_BUDGET_TOKENS = 1
|
|
21
19
|
|
|
22
20
|
module_function
|
|
23
21
|
|
|
@@ -124,6 +122,7 @@ module RubyLLM
|
|
|
124
122
|
output_tokens: usage['outputTokens'],
|
|
125
123
|
cache_read_tokens: usage['cacheReadInputTokens'],
|
|
126
124
|
cache_write_tokens: usage['cacheWriteInputTokens'],
|
|
125
|
+
cache_write_tokens_by_ttl: cache_write_by_ttl(usage),
|
|
127
126
|
thinking_tokens: reasoning_tokens(usage),
|
|
128
127
|
finish_reason: normalize_finish_reason(data['stopReason']),
|
|
129
128
|
model: data['modelId'] || @model&.id,
|
|
@@ -143,6 +142,15 @@ module RubyLLM
|
|
|
143
142
|
usage['reasoningTokens'] || usage.dig('outputTokensDetails', 'reasoningTokens')
|
|
144
143
|
end
|
|
145
144
|
|
|
145
|
+
def cache_write_by_ttl(usage)
|
|
146
|
+
details = usage['cacheDetails']
|
|
147
|
+
return unless details.is_a?(Array)
|
|
148
|
+
|
|
149
|
+
details.each_with_object(Hash.new(0)) do |detail, counts|
|
|
150
|
+
counts[detail['ttl']] += detail['inputTokens'].to_i if detail['ttl']
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
|
|
146
154
|
def format_messages(messages, caching: nil, automatic_cache_target: nil, citations: false)
|
|
147
155
|
rendered = []
|
|
148
156
|
tool_result_blocks = []
|
|
@@ -190,7 +198,9 @@ module RubyLLM
|
|
|
190
198
|
}
|
|
191
199
|
end
|
|
192
200
|
end
|
|
193
|
-
|
|
201
|
+
if cache_boundary?(msg, automatic_cache_target, caching:)
|
|
202
|
+
blocks << converse_cache_block_for(caching, ttl: msg.cache_ttl)
|
|
203
|
+
end
|
|
194
204
|
|
|
195
205
|
blocks
|
|
196
206
|
end
|
|
@@ -205,9 +215,13 @@ module RubyLLM
|
|
|
205
215
|
blocks
|
|
206
216
|
end
|
|
207
217
|
|
|
218
|
+
# Reasoning rebuilt from thinking text goes back only to the provider
|
|
219
|
+
# that produced it: Claude refuses another provider's signature, and
|
|
220
|
+
# a block without one.
|
|
208
221
|
def format_thinking_blocks(msg)
|
|
209
222
|
blocks = msg.raw_reasoning['converse'] if msg.raw_reasoning.is_a?(Hash)
|
|
210
223
|
return Support::Utils.deep_dup(blocks) if blocks
|
|
224
|
+
return [] unless msg.thinking && producer_slug(msg) == @provider.slug
|
|
211
225
|
|
|
212
226
|
[format_thinking_block(msg.thinking)].compact
|
|
213
227
|
end
|
|
@@ -257,7 +271,7 @@ module RubyLLM
|
|
|
257
271
|
messages.flat_map do |msg|
|
|
258
272
|
blocks = Media.format_content(msg.content, msg.attachments, used_document_names: @used_document_names)
|
|
259
273
|
if cache_boundary?(msg, automatic_cache_target, caching:)
|
|
260
|
-
blocks + [converse_cache_block_for(caching)]
|
|
274
|
+
blocks + [converse_cache_block_for(caching, ttl: msg.cache_ttl)]
|
|
261
275
|
else
|
|
262
276
|
blocks
|
|
263
277
|
end
|
|
@@ -278,10 +292,11 @@ module RubyLLM
|
|
|
278
292
|
caching != false && (message.cache_until_here? || message.equal?(automatic_cache_target))
|
|
279
293
|
end
|
|
280
294
|
|
|
281
|
-
def converse_cache_block_for(caching)
|
|
295
|
+
def converse_cache_block_for(caching, ttl: nil)
|
|
282
296
|
options = prompt_cache_options(caching)
|
|
297
|
+
ttl ||= options[:ttl]
|
|
283
298
|
point = { type: 'default' }
|
|
284
|
-
point[:ttl] =
|
|
299
|
+
point[:ttl] = ttl if ttl
|
|
285
300
|
{ cachePoint: point }
|
|
286
301
|
end
|
|
287
302
|
|
|
@@ -356,7 +371,7 @@ module RubyLLM
|
|
|
356
371
|
def format_additional_model_request_fields(thinking, model, max_output_tokens = nil)
|
|
357
372
|
fields = {}
|
|
358
373
|
|
|
359
|
-
reasoning_fields = format_reasoning_fields(thinking, model, max_output_tokens)
|
|
374
|
+
reasoning_fields = Reasoning.format_reasoning_fields(thinking, model, max_output_tokens)
|
|
360
375
|
fields = RubyLLM::Support::Utils.deep_merge(fields, reasoning_fields) if reasoning_fields
|
|
361
376
|
|
|
362
377
|
fields.empty? ? nil : fields
|
|
@@ -382,136 +397,12 @@ module RubyLLM
|
|
|
382
397
|
}
|
|
383
398
|
end
|
|
384
399
|
|
|
385
|
-
NOVA_DEFAULT_REASONING_EFFORT = 'low'
|
|
386
|
-
|
|
387
|
-
def format_reasoning_fields(thinking, model, max_output_tokens = nil)
|
|
388
|
-
return nil unless thinking&.enabled?
|
|
389
|
-
return format_nova_reasoning_fields(thinking, model) if nova_model?(model)
|
|
390
|
-
return { reasoning_config: { type: 'disabled' } } if thinking.enabled == false
|
|
391
|
-
|
|
392
|
-
effort = thinking.effort.to_s
|
|
393
|
-
budget = reasoning_budget(thinking, effort, model, max_output_tokens)
|
|
394
|
-
return { reasoning_config: { type: 'enabled', budget_tokens: budget } } if budget
|
|
395
|
-
return nil if effort.empty? || effort == 'none'
|
|
396
|
-
|
|
397
|
-
{ reasoning_effort: effort }
|
|
398
|
-
end
|
|
399
|
-
|
|
400
|
-
def nova_model?(model)
|
|
401
|
-
foundation_model_id(model&.id).start_with?('amazon.nova')
|
|
402
|
-
end
|
|
403
|
-
|
|
404
|
-
# Nova 2 takes reasoningConfig with an effort level instead of a token budget,
|
|
405
|
-
# and rejects an enabled reasoningConfig that names no effort.
|
|
406
|
-
def format_nova_reasoning_fields(thinking, model)
|
|
407
|
-
if thinking.budget
|
|
408
|
-
raise ArgumentError,
|
|
409
|
-
"#{model&.id} takes a reasoning effort, not a token budget of #{thinking.budget}. " \
|
|
410
|
-
'Pass with_thinking(effort:) instead.'
|
|
411
|
-
end
|
|
412
|
-
|
|
413
|
-
return { reasoningConfig: { type: 'disabled' } } if thinking.enabled == false
|
|
414
|
-
|
|
415
|
-
effort = thinking.effort.to_s
|
|
416
|
-
return nil if effort == 'none'
|
|
417
|
-
|
|
418
|
-
effort = NOVA_DEFAULT_REASONING_EFFORT if effort.empty?
|
|
419
|
-
{ reasoningConfig: { type: 'enabled', maxReasoningEffort: effort } }
|
|
420
|
-
end
|
|
421
|
-
|
|
422
|
-
def reasoning_budget(thinking, effort, model, max_output_tokens)
|
|
423
|
-
return thinking.budget if thinking.budget.is_a?(Integer)
|
|
424
|
-
return nil if effort.empty? || effort == 'none'
|
|
425
|
-
|
|
426
|
-
schema = reasoning_budget_schema(model)
|
|
427
|
-
schema && effort_budget_tokens(effort, schema, model, max_output_tokens)
|
|
428
|
-
end
|
|
429
|
-
|
|
430
|
-
# Bedrock only publishes Converse metadata for some regional entries, so use the
|
|
431
|
-
# schema from another entry for the same foundation model when needed.
|
|
432
|
-
def reasoning_budget_schema(model)
|
|
433
|
-
schema = budget_tokens_schema(model)
|
|
434
|
-
return schema if schema
|
|
435
|
-
return unless model
|
|
436
|
-
|
|
437
|
-
foundation_id = foundation_model_id(model.id)
|
|
438
|
-
RubyLLM.models.all.each do |candidate|
|
|
439
|
-
next unless candidate.provider == 'bedrock' && candidate.id != model.id
|
|
440
|
-
next unless foundation_model_id(candidate.id) == foundation_id
|
|
441
|
-
|
|
442
|
-
return schema if (schema = budget_tokens_schema(candidate))
|
|
443
|
-
end
|
|
444
|
-
|
|
445
|
-
nil
|
|
446
|
-
end
|
|
447
|
-
|
|
448
|
-
def budget_tokens_schema(model)
|
|
449
|
-
metadata = RubyLLM::Support::Utils.deep_symbolize_keys(model&.metadata || {})
|
|
450
|
-
raw_schema = metadata.dig(:converse, :additionalRequestFieldsSchema)
|
|
451
|
-
return unless raw_schema.is_a?(String)
|
|
452
|
-
|
|
453
|
-
schema = JSON.parse(raw_schema, symbolize_names: true)
|
|
454
|
-
budget = schema.is_a?(Hash) ? schema.dig(:reasoningConfig, :budgetTokens) : nil
|
|
455
|
-
budget if budget.is_a?(Hash)
|
|
456
|
-
rescue JSON::ParserError
|
|
457
|
-
nil
|
|
458
|
-
end
|
|
459
|
-
|
|
460
400
|
# Inference profile and foundation model ARNs name the model in their last segment.
|
|
461
401
|
def foundation_model_id(model_id)
|
|
462
402
|
prefixes = Converse::REGION_PREFIXES.join('|')
|
|
463
403
|
model_id.to_s.rpartition('/').last.sub(/\A(?:#{prefixes})\./, '')
|
|
464
404
|
end
|
|
465
405
|
|
|
466
|
-
# Models that take a budget reject reasoning_effort, so effort has to become a budget.
|
|
467
|
-
# Bedrock names the levels of an enumerated budget after the efforts they stand for;
|
|
468
|
-
# otherwise the effort spans the range the schema allows.
|
|
469
|
-
def effort_budget_tokens(effort, schema, model, max_output_tokens)
|
|
470
|
-
budget = enumerated_budget(effort, schema) || ranged_budget(effort, schema)
|
|
471
|
-
return nil unless budget
|
|
472
|
-
|
|
473
|
-
minimum = schema[:minimum].is_a?(Integer) ? schema[:minimum] : MINIMUM_BUDGET_TOKENS
|
|
474
|
-
return [budget, minimum].max unless max_output_tokens
|
|
475
|
-
|
|
476
|
-
budget.clamp(minimum, budget_ceiling(model, minimum, max_output_tokens))
|
|
477
|
-
end
|
|
478
|
-
|
|
479
|
-
# Bedrock rejects a budget that leaves no room for the answer.
|
|
480
|
-
def budget_ceiling(model, minimum, max_output_tokens)
|
|
481
|
-
ceiling = max_output_tokens - 1
|
|
482
|
-
return ceiling if minimum <= ceiling
|
|
483
|
-
|
|
484
|
-
raise ArgumentError, "#{model&.id} reasons on a budget of at least #{minimum} tokens, and " \
|
|
485
|
-
"max_output_tokens: #{max_output_tokens} leaves room for #{ceiling}. " \
|
|
486
|
-
"Raise max_output_tokens above #{minimum} or turn thinking off."
|
|
487
|
-
end
|
|
488
|
-
|
|
489
|
-
def enumerated_budget(effort, schema)
|
|
490
|
-
levels = schema[:enum]
|
|
491
|
-
level = levels.is_a?(Hash) ? levels[effort.to_sym] : nil
|
|
492
|
-
level if level.is_a?(Integer)
|
|
493
|
-
end
|
|
494
|
-
|
|
495
|
-
def ranged_budget(effort, schema)
|
|
496
|
-
minimum = schema[:minimum]
|
|
497
|
-
maximum = schema[:maximum]
|
|
498
|
-
return nil unless minimum.is_a?(Integer) && maximum.is_a?(Integer)
|
|
499
|
-
|
|
500
|
-
case effort
|
|
501
|
-
when 'low' then minimum
|
|
502
|
-
when 'medium' then minimum + ((maximum - minimum) / 2)
|
|
503
|
-
when 'high' then maximum
|
|
504
|
-
else raise ArgumentError, unknown_effort_message(effort, schema)
|
|
505
|
-
end
|
|
506
|
-
end
|
|
507
|
-
|
|
508
|
-
def unknown_effort_message(effort, schema)
|
|
509
|
-
levels = schema[:enum].is_a?(Hash) ? schema[:enum].keys.map(&:to_s) : []
|
|
510
|
-
levels |= RANGED_EFFORTS
|
|
511
|
-
"Bedrock has no reasoning budget for effort #{effort.inspect}. " \
|
|
512
|
-
"Use #{levels.join(', ')}, or pass an explicit budget."
|
|
513
|
-
end
|
|
514
|
-
|
|
515
406
|
def format_thinking_block(thinking)
|
|
516
407
|
return nil unless thinking
|
|
517
408
|
|
|
@@ -7,6 +7,20 @@ module RubyLLM
|
|
|
7
7
|
module Media
|
|
8
8
|
module_function
|
|
9
9
|
|
|
10
|
+
def supported_attachment?(attachment)
|
|
11
|
+
return supported_document_format?(attachment) if attachment.provider_file?
|
|
12
|
+
|
|
13
|
+
case attachment.type
|
|
14
|
+
when :image, :text then true
|
|
15
|
+
when :pdf, :document then supported_document_format?(attachment)
|
|
16
|
+
when :audio
|
|
17
|
+
SUPPORTED_AUDIO_FORMATS.include?(AUDIO_FORMAT_ALIASES.fetch(attachment.format, attachment.format))
|
|
18
|
+
when :video
|
|
19
|
+
SUPPORTED_VIDEO_FORMATS.include?(VIDEO_FORMAT_ALIASES.fetch(attachment.format, attachment.format))
|
|
20
|
+
else false
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
10
24
|
def format_content(content, attachments = [], used_document_names: nil, citations: false)
|
|
11
25
|
used_document_names ||= {}
|
|
12
26
|
blocks = []
|
|
@@ -18,6 +32,8 @@ module RubyLLM
|
|
|
18
32
|
end
|
|
19
33
|
|
|
20
34
|
def format_attachment(attachment, used_document_names:, citations: false)
|
|
35
|
+
raise UnsupportedAttachmentError, attachment.mime_type unless supported_attachment?(attachment)
|
|
36
|
+
|
|
21
37
|
if attachment.provider_file?
|
|
22
38
|
return format_provider_file_attachment(attachment, used_document_names:, citations:)
|
|
23
39
|
end
|
|
@@ -37,8 +53,6 @@ module RubyLLM
|
|
|
37
53
|
else
|
|
38
54
|
format_text_attachment(attachment)
|
|
39
55
|
end
|
|
40
|
-
else
|
|
41
|
-
raise UnsupportedAttachmentError, attachment.mime_type
|
|
42
56
|
end
|
|
43
57
|
end
|
|
44
58
|
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
5
|
+
module RubyLLM
|
|
6
|
+
module Protocols
|
|
7
|
+
class Converse
|
|
8
|
+
# Reasoning request fields for Bedrock Converse, whose shape depends on
|
|
9
|
+
# the model family.
|
|
10
|
+
module Reasoning
|
|
11
|
+
RANGED_EFFORTS = %w[low medium high].freeze
|
|
12
|
+
MINIMUM_BUDGET_TOKENS = 1
|
|
13
|
+
NOVA_DEFAULT_REASONING_EFFORT = 'low'
|
|
14
|
+
|
|
15
|
+
module_function
|
|
16
|
+
|
|
17
|
+
def format_reasoning_fields(thinking, model, max_output_tokens = nil)
|
|
18
|
+
return nil unless thinking&.enabled?
|
|
19
|
+
return format_nova_reasoning_fields(thinking, model) if nova_model?(model)
|
|
20
|
+
return disabled_reasoning_fields(model) if thinking.enabled == false
|
|
21
|
+
return format_adaptive_reasoning_fields(thinking) if adaptive_thinking?(thinking, model)
|
|
22
|
+
|
|
23
|
+
effort = thinking.effort.to_s
|
|
24
|
+
budget = reasoning_budget(thinking, effort, model, max_output_tokens)
|
|
25
|
+
return { reasoning_config: { type: 'enabled', budget_tokens: budget } } if budget
|
|
26
|
+
|
|
27
|
+
format_effort_fields(effort, model) unless effort.empty?
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def disabled_reasoning_fields(model)
|
|
31
|
+
return { thinking: { type: 'between_tools' } } if between_tools_off?(model)
|
|
32
|
+
|
|
33
|
+
{ reasoning_config: { type: 'disabled' } }
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def between_tools_off?(model)
|
|
37
|
+
return false unless model.respond_to?(:provider_class)
|
|
38
|
+
|
|
39
|
+
provider = model.provider_class
|
|
40
|
+
provider.respond_to?(:between_tools_off?) && provider.between_tools_off?(model.id)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Models that publish a reasoning_config enum, such as OpenAI's GPT
|
|
44
|
+
# models, take the effort as its value and reject reasoning_effort.
|
|
45
|
+
def format_effort_fields(effort, model)
|
|
46
|
+
return { reasoning_config: effort } if reasoning_config_schema(model)
|
|
47
|
+
|
|
48
|
+
{ reasoning_effort: effort } unless effort == 'none'
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Claude generations that take an effort but no budget only think
|
|
52
|
+
# adaptively, and reject a reasoning_config that enables a budget.
|
|
53
|
+
def adaptive_thinking?(thinking, model)
|
|
54
|
+
return false unless thinking.budget.nil?
|
|
55
|
+
return false unless Chat.foundation_model_id(model&.id).start_with?('anthropic.claude')
|
|
56
|
+
|
|
57
|
+
registered = registered_model(model)
|
|
58
|
+
!registered.nil? && !registered.reasoning_option(:effort).nil? &&
|
|
59
|
+
registered.reasoning_option(:budget_tokens).nil?
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def format_adaptive_reasoning_fields(thinking)
|
|
63
|
+
effort = thinking.effort.to_s
|
|
64
|
+
return nil if effort == 'none'
|
|
65
|
+
return { thinking: { type: 'adaptive' } } if effort.empty?
|
|
66
|
+
|
|
67
|
+
{ thinking: { type: 'adaptive' }, output_config: { effort: effort } }
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Inference profile ARNs and unlisted regional ids carry no reasoning
|
|
71
|
+
# options of their own, so use another entry for the same model.
|
|
72
|
+
def registered_model(model)
|
|
73
|
+
return model if model.reasoning_options.any?
|
|
74
|
+
|
|
75
|
+
foundation_id = Chat.foundation_model_id(model.id)
|
|
76
|
+
RubyLLM.models.all.find do |candidate|
|
|
77
|
+
candidate.provider == 'bedrock' && candidate.reasoning_options.any? &&
|
|
78
|
+
Chat.foundation_model_id(candidate.id) == foundation_id
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def nova_model?(model)
|
|
83
|
+
Chat.foundation_model_id(model&.id).start_with?('amazon.nova')
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Nova 2 takes reasoningConfig with an effort level instead of a token budget,
|
|
87
|
+
# and rejects an enabled reasoningConfig that names no effort.
|
|
88
|
+
def format_nova_reasoning_fields(thinking, model)
|
|
89
|
+
if thinking.budget
|
|
90
|
+
raise ArgumentError,
|
|
91
|
+
"#{model&.id} takes a reasoning effort, not a token budget of #{thinking.budget}. " \
|
|
92
|
+
'Pass with_thinking(effort:) instead.'
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
return { reasoningConfig: { type: 'disabled' } } if thinking.enabled == false
|
|
96
|
+
|
|
97
|
+
effort = thinking.effort.to_s
|
|
98
|
+
return nil if effort == 'none'
|
|
99
|
+
|
|
100
|
+
effort = NOVA_DEFAULT_REASONING_EFFORT if effort.empty?
|
|
101
|
+
{ reasoningConfig: { type: 'enabled', maxReasoningEffort: effort } }
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def reasoning_budget(thinking, effort, model, max_output_tokens)
|
|
105
|
+
return thinking.budget if thinking.budget.is_a?(Integer)
|
|
106
|
+
return nil if effort.empty? || effort == 'none'
|
|
107
|
+
|
|
108
|
+
schema = reasoning_budget_schema(model)
|
|
109
|
+
schema && effort_budget_tokens(effort, schema, max_output_tokens)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def reasoning_budget_schema(model)
|
|
113
|
+
request_fields_schema(model) { |schema| schema.dig(:reasoningConfig, :budgetTokens) }
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def reasoning_config_schema(model)
|
|
117
|
+
request_fields_schema(model) do |schema|
|
|
118
|
+
config = schema[:reasoning_config]
|
|
119
|
+
config if config.is_a?(Hash) && config[:type] == 'enum'
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Bedrock only publishes Converse metadata for some regional entries, so use the
|
|
124
|
+
# schema from another entry for the same foundation model when needed.
|
|
125
|
+
def request_fields_schema(model, &)
|
|
126
|
+
schema = published_schema(model, &)
|
|
127
|
+
return schema if schema
|
|
128
|
+
return unless model
|
|
129
|
+
|
|
130
|
+
foundation_id = Chat.foundation_model_id(model.id)
|
|
131
|
+
RubyLLM.models.all.each do |candidate|
|
|
132
|
+
next unless candidate.provider == 'bedrock' && candidate.id != model.id
|
|
133
|
+
next unless Chat.foundation_model_id(candidate.id) == foundation_id
|
|
134
|
+
|
|
135
|
+
return schema if (schema = published_schema(candidate, &))
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
nil
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def published_schema(model)
|
|
142
|
+
metadata = RubyLLM::Support::Utils.deep_symbolize_keys(model&.metadata || {})
|
|
143
|
+
raw_schema = metadata.dig(:converse, :additionalRequestFieldsSchema)
|
|
144
|
+
return unless raw_schema.is_a?(String)
|
|
145
|
+
|
|
146
|
+
schema = JSON.parse(raw_schema, symbolize_names: true)
|
|
147
|
+
found = schema.is_a?(Hash) ? yield(schema) : nil
|
|
148
|
+
found if found.is_a?(Hash)
|
|
149
|
+
rescue JSON::ParserError
|
|
150
|
+
nil
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# Models that take a budget reject reasoning_effort, so effort has to become a budget.
|
|
154
|
+
# Bedrock names the levels of an enumerated budget after the efforts they stand for;
|
|
155
|
+
# otherwise the effort spans the range the schema allows.
|
|
156
|
+
def effort_budget_tokens(effort, schema, max_output_tokens)
|
|
157
|
+
budget = enumerated_budget(effort, schema) || ranged_budget(effort, schema)
|
|
158
|
+
return nil unless budget
|
|
159
|
+
|
|
160
|
+
minimum = schema[:minimum].is_a?(Integer) ? schema[:minimum] : MINIMUM_BUDGET_TOKENS
|
|
161
|
+
return [budget, minimum].max unless max_output_tokens
|
|
162
|
+
|
|
163
|
+
budget.clamp(minimum, [max_output_tokens - 1, minimum].max)
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def enumerated_budget(effort, schema)
|
|
167
|
+
levels = schema[:enum]
|
|
168
|
+
level = levels.is_a?(Hash) ? levels[effort.to_sym] : nil
|
|
169
|
+
level if level.is_a?(Integer)
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def ranged_budget(effort, schema)
|
|
173
|
+
minimum = schema[:minimum]
|
|
174
|
+
maximum = schema[:maximum]
|
|
175
|
+
return nil unless minimum.is_a?(Integer) && maximum.is_a?(Integer)
|
|
176
|
+
|
|
177
|
+
case effort
|
|
178
|
+
when 'low' then minimum
|
|
179
|
+
when 'medium' then minimum + ((maximum - minimum) / 2)
|
|
180
|
+
when 'high' then maximum
|
|
181
|
+
else raise ArgumentError, unknown_effort_message(effort, schema)
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def unknown_effort_message(effort, schema)
|
|
186
|
+
levels = schema[:enum].is_a?(Hash) ? schema[:enum].keys.map(&:to_s) : []
|
|
187
|
+
levels |= RANGED_EFFORTS
|
|
188
|
+
"Bedrock has no reasoning budget for effort #{effort.inspect}. " \
|
|
189
|
+
"Use #{levels.join(', ')}, or pass an explicit budget."
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
end
|