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
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyLLM
|
|
4
|
+
module Protocols
|
|
5
|
+
class Converse
|
|
6
|
+
# Describes Converse requests, and the token counting requests that
|
|
7
|
+
# wrap them, as RequestShape objects. A content block is a union keyed
|
|
8
|
+
# by its kind, and media names a format instead of a MIME type.
|
|
9
|
+
# Converse wants user and assistant messages to alternate, pairs tool
|
|
10
|
+
# results with uses by id, and takes reasoning back only with its
|
|
11
|
+
# signature.
|
|
12
|
+
module RequestShapes
|
|
13
|
+
MEDIA_KINDS = { 'image' => :image, 'audio' => :audio, 'video' => :video, 'document' => :document }.freeze
|
|
14
|
+
|
|
15
|
+
def parse_request_shape(payload)
|
|
16
|
+
request = shape_hash(shape_hash(payload['input'])['converse'])
|
|
17
|
+
request = payload if request.empty?
|
|
18
|
+
messages = request['messages']
|
|
19
|
+
return unless messages.is_a?(Array)
|
|
20
|
+
|
|
21
|
+
turns = messages.each_with_index.map { |message, index| shape_converse_turn(shape_hash(message), index) }
|
|
22
|
+
tools = shape_list(shape_hash(request['toolConfig'])['tools'])
|
|
23
|
+
shape_request(turns, payload:, rules: %i[alternating_roles signed_thinking],
|
|
24
|
+
instructions: shape_converse_blocks(request['system']),
|
|
25
|
+
tool_names: tools.map { |tool| shape_converse_tool_name(shape_hash(tool)) },
|
|
26
|
+
thinking_settings: shape_converse_thinking(request))
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def shape_converse_turn(message, index)
|
|
32
|
+
parts = shape_converse_blocks(message['content'])
|
|
33
|
+
shape_turn(index, message['role'], message['role'] == 'assistant' ? :model : shape_answer(parts), parts)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def shape_converse_blocks(blocks)
|
|
37
|
+
shape_list(blocks).flat_map { |block| shape_converse_block(shape_hash(block)) }
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def shape_converse_block(block)
|
|
41
|
+
type, value = block.first
|
|
42
|
+
return shape_text(value) if %w[text json].include?(type)
|
|
43
|
+
|
|
44
|
+
value = shape_hash(value)
|
|
45
|
+
case type
|
|
46
|
+
when 'toolUse' then shape_tool_call(value['name'], value['input'], id: value['toolUseId'])
|
|
47
|
+
when 'toolResult'
|
|
48
|
+
shape_tool_result(value['content'], id: value['toolUseId']) { |inner| shape_converse_block(inner) }
|
|
49
|
+
when 'reasoningContent' then shape_converse_reasoning(value)
|
|
50
|
+
when *MEDIA_KINDS.keys then shape_converse_media(MEDIA_KINDS[type], value)
|
|
51
|
+
else shape_other(type)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def shape_converse_reasoning(reasoning)
|
|
56
|
+
unless reasoning.key?('reasoningText')
|
|
57
|
+
return shape_text(nil, kind: :thinking, signed: shape_signature?(reasoning['redactedContent']))
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
text = shape_hash(reasoning['reasoningText'])
|
|
61
|
+
shape_text(text['text'], kind: :thinking, signed: shape_signature?(text['signature']))
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def shape_converse_media(kind, media)
|
|
65
|
+
source = shape_hash(media['source'])
|
|
66
|
+
mime_type = shape_converse_mime_type(kind, media['format'])
|
|
67
|
+
return shape_inline(source['bytes'], kind:, mime_type:) if source.key?('bytes')
|
|
68
|
+
return shape_file(kind:, mime_type:) unless source.key?('text')
|
|
69
|
+
|
|
70
|
+
shape_media(kind, source: :inline, size: shape_length(source['text']), mime_type:)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# A format can name a container that holds either sound or video,
|
|
74
|
+
# such as mp4, so its MIME type counts only when it agrees.
|
|
75
|
+
def shape_converse_mime_type(kind, format)
|
|
76
|
+
return unless format.is_a?(String)
|
|
77
|
+
|
|
78
|
+
mime_type = RubyLLM::Files::MimeType.for(name: "file.#{format}")
|
|
79
|
+
mime_type if mime_type != 'application/octet-stream' && shape_media_kind(mime_type) == kind
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def shape_converse_tool_name(tool)
|
|
83
|
+
shape_hash(tool['toolSpec'])['name'] || shape_hash(tool['systemTool'])['name']
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Each model family takes its reasoning settings in a field of its
|
|
87
|
+
# own, and some name an effort with a bare reasoning_config.
|
|
88
|
+
def shape_converse_thinking(request)
|
|
89
|
+
fields = shape_hash(request['additionalModelRequestFields'])
|
|
90
|
+
shape_settings(fields, 'reasoning_config', 'reasoning_effort')
|
|
91
|
+
.merge(shape_settings(fields['reasoning_config'], 'type', 'budget_tokens'))
|
|
92
|
+
.merge(shape_settings(fields['reasoningConfig'], 'type', 'maxReasoningEffort'))
|
|
93
|
+
.merge(shape_settings(fields['thinking'], 'type'))
|
|
94
|
+
.merge(shape_settings(fields['output_config'], 'effort'))
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
@@ -31,8 +31,7 @@ module RubyLLM
|
|
|
31
31
|
on_failed_response: ->(chunk, env) { handle_failed_stream(chunk, env) }
|
|
32
32
|
)
|
|
33
33
|
|
|
34
|
-
response = @connection.post(stream_url,
|
|
35
|
-
req.headers.merge!(@provider.sign_headers('POST', stream_url, body))
|
|
34
|
+
response = @connection.post(stream_url, body, usage: @usage_tracker, stream: true) do |req|
|
|
36
35
|
req.headers.merge!(additional_headers) unless additional_headers.empty?
|
|
37
36
|
req.headers['Accept'] = 'application/vnd.amazon.eventstream'
|
|
38
37
|
(req.options.context ||= {})[RubyLLM::Transport::Connection::STREAM_PROGRESS_KEY] = progress
|
|
@@ -203,6 +202,7 @@ module RubyLLM
|
|
|
203
202
|
output_tokens: extract_output_tokens(metadata_usage, usage),
|
|
204
203
|
cache_read_tokens: extract_cache_read_tokens(metadata_usage, usage),
|
|
205
204
|
cache_write_tokens: extract_cache_write_tokens(metadata_usage, usage),
|
|
205
|
+
cache_write_tokens_by_ttl: Chat.cache_write_by_ttl(metadata_usage) || Chat.cache_write_by_ttl(usage),
|
|
206
206
|
thinking_tokens: extract_reasoning_tokens(metadata_usage, usage),
|
|
207
207
|
finish_reason: extract_finish_reason(event)
|
|
208
208
|
)
|
|
@@ -37,7 +37,8 @@ module RubyLLM
|
|
|
37
37
|
else
|
|
38
38
|
text = (block['reasoningText'] ||= { 'text' => '' })
|
|
39
39
|
(delta['reasoningText'] || delta).slice('text', 'signature').each do |key, value|
|
|
40
|
-
text[key] = text[key].to_s
|
|
40
|
+
text[key] = +text[key].to_s
|
|
41
|
+
text[key] << value.to_s
|
|
41
42
|
end
|
|
42
43
|
end
|
|
43
44
|
end
|
|
@@ -4,10 +4,11 @@ module RubyLLM
|
|
|
4
4
|
module Protocols
|
|
5
5
|
# The AWS Bedrock Converse API. Requests are SigV4-signed by the provider.
|
|
6
6
|
class Converse < Protocol
|
|
7
|
-
REGION_PREFIXES = %w[global us-gov us eu apac ap sa ca me af il au jp].freeze
|
|
7
|
+
REGION_PREFIXES = %w[global us-gov us eu apac ap sa ca me af il au jp in].freeze
|
|
8
8
|
|
|
9
9
|
include Converse::Chat
|
|
10
10
|
include Converse::Media
|
|
11
|
+
include Converse::RequestShapes
|
|
11
12
|
include Converse::Streaming
|
|
12
13
|
|
|
13
14
|
# Nova 2 models execute built-in tools server-side when the request
|
|
@@ -42,10 +43,7 @@ module RubyLLM
|
|
|
42
43
|
end
|
|
43
44
|
|
|
44
45
|
def signed_post(url, payload, additional_headers = {})
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
@connection.post(url, payload, usage: @usage_tracker) do |req|
|
|
48
|
-
req.headers.merge!(@provider.sign_headers('POST', url, body))
|
|
46
|
+
@connection.post(url, JSON.generate(payload), usage: @usage_tracker) do |req|
|
|
49
47
|
req.headers.merge!(additional_headers) unless additional_headers.empty?
|
|
50
48
|
end
|
|
51
49
|
end
|
|
@@ -5,21 +5,14 @@ module RubyLLM
|
|
|
5
5
|
module DeepSeek
|
|
6
6
|
# DeepSeek stores image files for reuse by vision models.
|
|
7
7
|
class Files < Protocols::OpenAI::Files
|
|
8
|
-
IMAGE_TYPES = %w[image/jpeg image/png image/gif image/webp].freeze
|
|
9
|
-
MAX_FILE_SIZE = 64 * 1024 * 1024
|
|
10
|
-
|
|
11
8
|
def download(_file_id)
|
|
12
9
|
raise Error, 'DeepSeek does not support downloading uploaded files'
|
|
13
10
|
end
|
|
14
11
|
|
|
15
12
|
private
|
|
16
13
|
|
|
17
|
-
def render_upload_payload(attachment, purpose: nil, **
|
|
18
|
-
|
|
19
|
-
raise ArgumentError, 'DeepSeek image uploads cannot exceed 64 MiB' if file_size(attachment) > MAX_FILE_SIZE
|
|
20
|
-
raise ArgumentError, 'DeepSeek file uploads require purpose: user_data' if purpose && purpose != 'user_data'
|
|
21
|
-
|
|
22
|
-
super(attachment, purpose: 'user_data', **options)
|
|
14
|
+
def render_upload_payload(attachment, purpose: nil, **)
|
|
15
|
+
super(attachment, purpose: purpose || 'user_data', **)
|
|
23
16
|
end
|
|
24
17
|
|
|
25
18
|
def uploaded_file(data, **attributes)
|
|
@@ -5,8 +5,6 @@ module RubyLLM
|
|
|
5
5
|
class ElevenLabs
|
|
6
6
|
class Flows
|
|
7
7
|
module Images # :nodoc: all
|
|
8
|
-
MASK_MODELS = %w[gpt-image-1 gpt-image-1.5 gpt-image-2].freeze
|
|
9
|
-
|
|
10
8
|
def images_url(**)
|
|
11
9
|
'v1/flows/image'
|
|
12
10
|
end
|
|
@@ -20,11 +18,7 @@ module RubyLLM
|
|
|
20
18
|
payload = { model_id: model, prompt: }
|
|
21
19
|
payload[:images] = images.map { |image| render_media_reference(image) } if images.any?
|
|
22
20
|
payload[:aspect_ratio] = image_aspect_ratio(size) if size && size != 'auto'
|
|
23
|
-
if mask
|
|
24
|
-
raise ArgumentError, 'ElevenLabs image masks require a GPT Image model' unless MASK_MODELS.include?(model)
|
|
25
|
-
|
|
26
|
-
payload[:mask] = render_media_reference(Attachment.wrap(mask, config: @config).first)
|
|
27
|
-
end
|
|
21
|
+
payload[:mask] = render_media_reference(Attachment.wrap(mask, config: @config).first) if mask
|
|
28
22
|
Support::Utils.deep_merge(payload, provider_options)
|
|
29
23
|
end
|
|
30
24
|
|
|
@@ -5,10 +5,6 @@ module RubyLLM
|
|
|
5
5
|
class ElevenLabs
|
|
6
6
|
class Flows
|
|
7
7
|
module Videos # :nodoc: all
|
|
8
|
-
REFERENCE_VIDEO_MODELS = %w[
|
|
9
|
-
bytedance-seedance-v2 bytedance-seedance-v2-fast bytedance-seedance-v2-mini bytedance-seedance-v2.5
|
|
10
|
-
].freeze
|
|
11
|
-
|
|
12
8
|
def video_url
|
|
13
9
|
'v1/flows/video'
|
|
14
10
|
end
|
|
@@ -17,22 +13,20 @@ module RubyLLM
|
|
|
17
13
|
payload = if model == 'creatify-aurora'
|
|
18
14
|
render_character_animation(prompt, with:)
|
|
19
15
|
else
|
|
20
|
-
render_video_inputs(prompt,
|
|
16
|
+
render_video_inputs(prompt, with:)
|
|
21
17
|
end
|
|
22
18
|
Support::Utils.deep_merge({ model_id: model }.merge(payload), provider_options)
|
|
23
19
|
end
|
|
24
20
|
|
|
25
|
-
def render_video_inputs(prompt,
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
payload = { prompt: }
|
|
21
|
+
def render_video_inputs(prompt, with:)
|
|
22
|
+
payload = { prompt: }.compact
|
|
29
23
|
if with.all?(&:image?)
|
|
30
24
|
raise ArgumentError, 'Video generation accepts at most two frame images' if with.size > 2
|
|
31
25
|
|
|
32
26
|
payload[:start_frame] = render_media_reference(with[0]) if with[0]
|
|
33
27
|
payload[:end_frame] = render_media_reference(with[1]) if with[1]
|
|
34
28
|
else
|
|
35
|
-
render_video_references(payload, with
|
|
29
|
+
render_video_references(payload, with)
|
|
36
30
|
end
|
|
37
31
|
payload
|
|
38
32
|
end
|
|
@@ -51,19 +45,19 @@ module RubyLLM
|
|
|
51
45
|
{ image: render_media_reference(image.first), audio: render_media_reference(audio.first) }
|
|
52
46
|
end
|
|
53
47
|
|
|
54
|
-
def render_video_references(payload, attachments
|
|
55
|
-
unless REFERENCE_VIDEO_MODELS.include?(model)
|
|
56
|
-
raise ArgumentError, 'This ElevenLabs video model only accepts image attachments'
|
|
57
|
-
end
|
|
58
|
-
|
|
48
|
+
def render_video_references(payload, attachments)
|
|
59
49
|
{ images: :image?, audios: :audio?, videos: :video? }.each do |field, predicate|
|
|
60
50
|
references = attachments.select { |attachment| attachment.public_send(predicate) }
|
|
61
51
|
payload[field] = references.map { |attachment| render_media_reference(attachment) } if references.any?
|
|
62
52
|
end
|
|
63
53
|
end
|
|
64
54
|
|
|
65
|
-
def parse_video_job(response, model
|
|
66
|
-
VideoJob.new(id: response.body.fetch('id'), protocol: self, model:, raw: response.body)
|
|
55
|
+
def parse_video_job(response, model:, **request)
|
|
56
|
+
VideoJob.new(id: response.body.fetch('id'), protocol: self, model:, raw: response.body, **request)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def video_request_settings(request)
|
|
60
|
+
{ duration: request[:duration_secs], resolution: request[:resolution] }
|
|
67
61
|
end
|
|
68
62
|
|
|
69
63
|
def video_job_url(job)
|
|
@@ -4,8 +4,6 @@ module RubyLLM
|
|
|
4
4
|
module Protocols
|
|
5
5
|
class ElevenLabs
|
|
6
6
|
module StreamingTranscription # :nodoc: all
|
|
7
|
-
SAMPLE_RATES = [8000, 16_000, 22_050, 24_000, 44_100, 48_000].freeze
|
|
8
|
-
|
|
9
7
|
def stream_transcription(payload, model:, &block)
|
|
10
8
|
validate_streaming_transcription(payload)
|
|
11
9
|
audio = RubyLLM::Transcription::WavAudio.new(payload.fetch(:file).io.read)
|
|
@@ -74,8 +72,7 @@ module RubyLLM
|
|
|
74
72
|
end
|
|
75
73
|
|
|
76
74
|
def pcm_audio?(audio)
|
|
77
|
-
[audio.channels, audio.encoding, audio.bits_per_sample] == [1, 1, 16]
|
|
78
|
-
SAMPLE_RATES.include?(audio.sample_rate)
|
|
75
|
+
[audio.channels, audio.encoding, audio.bits_per_sample] == [1, 1, 16]
|
|
79
76
|
end
|
|
80
77
|
|
|
81
78
|
def streaming_transcription_url(payload, audio_format:)
|
|
@@ -15,7 +15,6 @@ module RubyLLM
|
|
|
15
15
|
|
|
16
16
|
GEMINI_INLINE_FILE_THRESHOLD = 20 * 1024 * 1024
|
|
17
17
|
VERTEX_INLINE_FILE_THRESHOLD = 7 * 1024 * 1024
|
|
18
|
-
GEMINI_FILE_UPLOAD_LIMIT = 2 * 1024 * 1024 * 1024
|
|
19
18
|
|
|
20
19
|
module_function
|
|
21
20
|
|
|
@@ -84,8 +83,8 @@ module RubyLLM
|
|
|
84
83
|
"models/#{@model.id}:countTokens"
|
|
85
84
|
end
|
|
86
85
|
|
|
87
|
-
def render_count_tokens_payload(messages, model:, **
|
|
88
|
-
request = count_tokens_request(messages, model: model, **
|
|
86
|
+
def render_count_tokens_payload(messages, model:, **)
|
|
87
|
+
request = count_tokens_request(messages, model: model, **)
|
|
89
88
|
{ generateContentRequest: request.merge(model: "models/#{model.id}") }
|
|
90
89
|
end
|
|
91
90
|
|
|
@@ -128,10 +127,6 @@ module RubyLLM
|
|
|
128
127
|
@provider.slug == 'vertexai' ? VERTEX_INLINE_FILE_THRESHOLD : GEMINI_INLINE_FILE_THRESHOLD
|
|
129
128
|
end
|
|
130
129
|
|
|
131
|
-
def provider_file_upload_limit
|
|
132
|
-
GEMINI_FILE_UPLOAD_LIMIT
|
|
133
|
-
end
|
|
134
|
-
|
|
135
130
|
def provider_file_attachable?(attachment)
|
|
136
131
|
attachment.image? || attachment.video? || attachment.audio? || attachment.pdf? || attachment.text?
|
|
137
132
|
end
|
|
@@ -160,7 +155,7 @@ module RubyLLM
|
|
|
160
155
|
end
|
|
161
156
|
|
|
162
157
|
def format_parts(msg)
|
|
163
|
-
if msg.role == :assistant && msg.raw_content
|
|
158
|
+
if msg.role == :assistant && msg.raw_content.is_a?(Array)
|
|
164
159
|
msg.raw_content
|
|
165
160
|
elsif msg.tool_call?
|
|
166
161
|
format_tool_call(msg)
|
|
@@ -172,19 +167,20 @@ module RubyLLM
|
|
|
172
167
|
end
|
|
173
168
|
|
|
174
169
|
def format_message_parts(msg)
|
|
175
|
-
parts =
|
|
170
|
+
parts = Media.format_content(msg.content, msg.attachments)
|
|
171
|
+
thinking = msg.thinking if msg.role == :assistant
|
|
172
|
+
return parts unless thinking
|
|
176
173
|
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
parts
|
|
174
|
+
signature = own_signature(msg)
|
|
175
|
+
parts = sign_last_part(parts, signature) if signature
|
|
176
|
+
thinking.text ? [{ thought: true, text: thinking.text }, *parts] : parts
|
|
181
177
|
end
|
|
182
178
|
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
179
|
+
# Gemini signs the last part of an answer. An answer without parts gets
|
|
180
|
+
# the empty text part a stream ends with: Gemini refuses a part without data.
|
|
181
|
+
def sign_last_part(parts, signature)
|
|
182
|
+
parts = [Media.format_text('')] if parts.empty?
|
|
183
|
+
[*parts[...-1], parts.last.merge(thoughtSignature: signature)]
|
|
188
184
|
end
|
|
189
185
|
|
|
190
186
|
def parse_completion_body(data, raw:)
|
|
@@ -203,11 +199,12 @@ module RubyLLM
|
|
|
203
199
|
),
|
|
204
200
|
tool_calls: tool_calls,
|
|
205
201
|
server_tool_calls: extract_server_tool_calls(data, parts),
|
|
206
|
-
raw_content: parts.any? { |part| server_tool_part?(part) } ? parts : nil,
|
|
202
|
+
raw_content: parts.any? { |part| server_tool_part?(part) } ? without_search_suggestions(parts) : nil,
|
|
207
203
|
input_tokens: input_tokens(data),
|
|
208
204
|
output_tokens: calculate_output_tokens(data),
|
|
209
205
|
cache_read_tokens: data.dig('usageMetadata', 'cachedContentTokenCount'),
|
|
210
206
|
thinking_tokens: data.dig('usageMetadata', 'thoughtsTokenCount'),
|
|
207
|
+
server_tool_use: parse_server_tool_use(data),
|
|
211
208
|
finish_reason: normalize_finish_reason(
|
|
212
209
|
data.dig('candidates', 0, 'finishReason') || data.dig('promptFeedback', 'blockReason')
|
|
213
210
|
),
|
|
@@ -216,6 +213,14 @@ module RubyLLM
|
|
|
216
213
|
)
|
|
217
214
|
end
|
|
218
215
|
|
|
216
|
+
# Gemini 3 bills each distinct, non-empty query a grounding ran.
|
|
217
|
+
def parse_server_tool_use(data)
|
|
218
|
+
searches = Array(data['candidates']).sum do |candidate|
|
|
219
|
+
Array(candidate.dig('groundingMetadata', 'webSearchQueries')).reject(&:empty?).uniq.size
|
|
220
|
+
end
|
|
221
|
+
{ 'web_search_requests' => searches }
|
|
222
|
+
end
|
|
223
|
+
|
|
219
224
|
def input_tokens(data)
|
|
220
225
|
prompt_tokens = data.dig('usageMetadata', 'promptTokenCount')
|
|
221
226
|
return unless prompt_tokens
|
|
@@ -243,6 +248,18 @@ module RubyLLM
|
|
|
243
248
|
part.key?('executableCode') || part.key?('codeExecutionResult')
|
|
244
249
|
end
|
|
245
250
|
|
|
251
|
+
# Google's terms forbid storing search suggestions, and Gemini takes
|
|
252
|
+
# a replayed search result without them.
|
|
253
|
+
def without_search_suggestions(parts)
|
|
254
|
+
parts.map do |part|
|
|
255
|
+
tool_response = part['toolResponse']
|
|
256
|
+
response = tool_response&.dig('response')
|
|
257
|
+
next part unless response.is_a?(Hash) && response.key?('search_suggestions')
|
|
258
|
+
|
|
259
|
+
part.merge('toolResponse' => tool_response.merge('response' => response.except('search_suggestions')))
|
|
260
|
+
end
|
|
261
|
+
end
|
|
262
|
+
|
|
246
263
|
def extract_server_tool_calls(data, parts)
|
|
247
264
|
calls = parts.select { |part| server_tool_part?(part) }.map do |part|
|
|
248
265
|
ServerToolCall.new(
|
|
@@ -263,7 +280,9 @@ module RubyLLM
|
|
|
263
280
|
queries = candidate.dig('groundingMetadata', 'webSearchQueries')
|
|
264
281
|
if queries&.any?
|
|
265
282
|
calls << ServerToolCall.new(type: 'google_search', input: { 'queries' => queries },
|
|
266
|
-
raw: { 'webSearchQueries' => queries }
|
|
283
|
+
raw: { 'webSearchQueries' => queries },
|
|
284
|
+
search_suggestions: candidate.dig('groundingMetadata', 'searchEntryPoint',
|
|
285
|
+
'renderedContent'))
|
|
267
286
|
end
|
|
268
287
|
|
|
269
288
|
url_metadata = candidate['urlContextMetadata']
|
|
@@ -370,6 +389,7 @@ module RubyLLM
|
|
|
370
389
|
@messages = messages
|
|
371
390
|
@index = 0
|
|
372
391
|
@tool_call_names = {}
|
|
392
|
+
@turn_start = messages.rindex { |message| message.role == :user }
|
|
373
393
|
end
|
|
374
394
|
|
|
375
395
|
def format
|
|
@@ -437,12 +457,21 @@ module RubyLLM
|
|
|
437
457
|
end
|
|
438
458
|
|
|
439
459
|
def build_standard_message(message)
|
|
460
|
+
parts = @provider.send(:format_parts, message)
|
|
461
|
+
parts = @provider.send(:sign_step, parts) if message.tool_call? && current_turn?
|
|
462
|
+
|
|
440
463
|
{
|
|
441
464
|
role: @provider.send(:format_role, message.role),
|
|
442
|
-
parts:
|
|
465
|
+
parts: parts
|
|
443
466
|
}
|
|
444
467
|
end
|
|
445
468
|
|
|
469
|
+
# Gemini checks signatures only in the current turn, which starts at
|
|
470
|
+
# the last user message.
|
|
471
|
+
def current_turn?
|
|
472
|
+
@turn_start.nil? || @index > @turn_start
|
|
473
|
+
end
|
|
474
|
+
|
|
446
475
|
def format_tool_result(message, tool_name)
|
|
447
476
|
@provider.send(:format_tool_result, message, tool_name)
|
|
448
477
|
end
|
|
@@ -30,8 +30,8 @@ module RubyLLM
|
|
|
30
30
|
true
|
|
31
31
|
end
|
|
32
32
|
|
|
33
|
-
def render_embedding(text, dimensions: nil, **
|
|
34
|
-
payload = render_embedding_payload(text, dimensions:, **
|
|
33
|
+
def render_embedding(text, dimensions: nil, **)
|
|
34
|
+
payload = render_embedding_payload(text, dimensions:, **)
|
|
35
35
|
return payload if text.is_a?(Array) || !payload.key?(:requests)
|
|
36
36
|
|
|
37
37
|
payload.fetch(:requests).first
|
|
@@ -102,7 +102,7 @@ module RubyLLM
|
|
|
102
102
|
def gemini_connection(request_json:, response_json:)
|
|
103
103
|
connection = Transport::Connection.basic(@config) do |connection|
|
|
104
104
|
connection.request :json if request_json
|
|
105
|
-
connection.
|
|
105
|
+
connection.use Transport::JsonResponse if response_json
|
|
106
106
|
connection.adapter @config.faraday_adapter
|
|
107
107
|
connection.use :llm_errors, provider: @provider
|
|
108
108
|
end
|
|
@@ -125,7 +125,11 @@ module RubyLLM
|
|
|
125
125
|
|
|
126
126
|
def parse_gemini_image_responses(data, model:)
|
|
127
127
|
parts = gemini_image_parts(data)
|
|
128
|
-
|
|
128
|
+
if parts.empty?
|
|
129
|
+
usage = gemini_image_usage(data)
|
|
130
|
+
@usage_tracker&.observe_tokens(Tokens.new(input: usage['input_tokens'], output: usage['output_tokens']))
|
|
131
|
+
raise Error, 'Unexpected response format from Gemini image generation API'
|
|
132
|
+
end
|
|
129
133
|
|
|
130
134
|
parts.map.with_index do |image_data, index|
|
|
131
135
|
Image.new(
|
|
@@ -10,23 +10,27 @@ module RubyLLM
|
|
|
10
10
|
module Media
|
|
11
11
|
module_function
|
|
12
12
|
|
|
13
|
+
def supported_attachment?(attachment)
|
|
14
|
+
%i[image audio video pdf text].include?(attachment.type)
|
|
15
|
+
end
|
|
16
|
+
|
|
13
17
|
def format_content(content, attachments = [])
|
|
14
18
|
parts = []
|
|
15
19
|
parts << format_text(content) if content
|
|
16
20
|
|
|
17
21
|
attachments.each do |attachment|
|
|
18
|
-
parts << format_content_attachment(attachment)
|
|
22
|
+
parts << with_media_resolution(format_content_attachment(attachment), attachment)
|
|
19
23
|
end
|
|
20
24
|
|
|
21
25
|
parts
|
|
22
26
|
end
|
|
23
27
|
|
|
24
28
|
def format_content_attachment(attachment)
|
|
29
|
+
raise UnsupportedAttachmentError, attachment.mime_type unless supported_attachment?(attachment)
|
|
30
|
+
|
|
25
31
|
case attachment.type
|
|
26
32
|
when :text
|
|
27
33
|
format_text_file(attachment)
|
|
28
|
-
when :document, :unknown
|
|
29
|
-
raise UnsupportedAttachmentError, attachment.mime_type
|
|
30
34
|
else
|
|
31
35
|
format_attachment(attachment)
|
|
32
36
|
end
|
|
@@ -43,6 +47,14 @@ module RubyLLM
|
|
|
43
47
|
}
|
|
44
48
|
end
|
|
45
49
|
|
|
50
|
+
def with_media_resolution(part, attachment)
|
|
51
|
+
return part unless attachment.resolution && %i[image video pdf].include?(attachment.type)
|
|
52
|
+
|
|
53
|
+
level = attachment.resolution == :original ? :ultra_high : attachment.resolution
|
|
54
|
+
level = :high if level == :ultra_high && attachment.type != :image
|
|
55
|
+
part.merge(media_resolution: { level: "MEDIA_RESOLUTION_#{level.upcase}" })
|
|
56
|
+
end
|
|
57
|
+
|
|
46
58
|
def format_file_data(attachment)
|
|
47
59
|
uri = attachment.provider_file_uri || attachment.provider_file_id
|
|
48
60
|
{
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyLLM
|
|
4
|
+
module Protocols
|
|
5
|
+
class Gemini
|
|
6
|
+
# Describes generateContent requests, and the countTokens requests that
|
|
7
|
+
# wrap them, as RequestShape objects. RubyLLM writes media parts in
|
|
8
|
+
# snake case, and a model's own parts replay in camel case. Gemini pairs
|
|
9
|
+
# function responses with calls by position and name, wants data in
|
|
10
|
+
# every part, and from Gemini 3 needs a signature on the first call of
|
|
11
|
+
# each step in the current turn.
|
|
12
|
+
module RequestShapes
|
|
13
|
+
PART_MODIFIERS = %w[thought thoughtSignature thought_signature mediaResolution media_resolution
|
|
14
|
+
videoMetadata video_metadata partMetadata part_metadata].freeze
|
|
15
|
+
DATA_FIELDS = %w[text inlineData inline_data fileData file_data functionCall function_call functionResponse
|
|
16
|
+
function_response executableCode executable_code codeExecutionResult code_execution_result
|
|
17
|
+
toolCall tool_call toolResponse tool_response].freeze
|
|
18
|
+
FUNCTION_DECLARATIONS = %w[functionDeclarations function_declarations].freeze
|
|
19
|
+
THINKING_SETTINGS = %w[includeThoughts include_thoughts thinkingBudget thinking_budget thinkingLevel
|
|
20
|
+
thinking_level].freeze
|
|
21
|
+
|
|
22
|
+
def parse_request_shape(payload)
|
|
23
|
+
request = shape_hash(payload['generateContentRequest'])
|
|
24
|
+
request = payload if request.empty?
|
|
25
|
+
contents = request['contents']
|
|
26
|
+
return unless contents.is_a?(Array)
|
|
27
|
+
|
|
28
|
+
turns = contents.each_with_index.map { |content, index| shape_gemini_turn(shape_hash(content), index) }
|
|
29
|
+
shape_request(turns, payload:, pairing: :position, rules: %i[signed_steps],
|
|
30
|
+
instructions: shape_gemini_parts(request['systemInstruction'] ||
|
|
31
|
+
request['system_instruction']),
|
|
32
|
+
tool_names: shape_list(request['tools']).flat_map { |tool| shape_gemini_tools(tool) },
|
|
33
|
+
thinking_settings: shape_gemini_thinking(request))
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def shape_gemini_turn(content, index)
|
|
39
|
+
parts = shape_gemini_parts(content)
|
|
40
|
+
shape_turn(index, content['role'], content['role'] == 'model' ? :model : shape_answer(parts), parts)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def shape_gemini_parts(content)
|
|
44
|
+
shape_list(shape_hash(content)['parts']).flat_map { |part| shape_gemini_part(shape_hash(part)) }
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def shape_gemini_part(part)
|
|
48
|
+
signed = shape_signature?(part['thoughtSignature'] || part['thought_signature'])
|
|
49
|
+
call = shape_gemini_field(part, 'functionCall', 'function_call')
|
|
50
|
+
response = shape_gemini_field(part, 'functionResponse', 'function_response')
|
|
51
|
+
|
|
52
|
+
if call
|
|
53
|
+
shape_tool_call(call['name'], call['args'], id: call['id'], signed:)
|
|
54
|
+
elsif response
|
|
55
|
+
shape_gemini_tool_result(response)
|
|
56
|
+
elsif part.key?('text')
|
|
57
|
+
shape_text(part['text'], kind: part['thought'] ? :thinking : :text, signed:)
|
|
58
|
+
else
|
|
59
|
+
shape_gemini_data(part, signed)
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# RubyLLM wraps a result's text in content parts, and Gemini 3 sends
|
|
64
|
+
# a result's media in parts beside it.
|
|
65
|
+
def shape_gemini_tool_result(response)
|
|
66
|
+
result = response['response']
|
|
67
|
+
content = shape_hash(result)['content']
|
|
68
|
+
content = result unless content.is_a?(Array)
|
|
69
|
+
shape_tool_result(content, name: response['name']) { |part| shape_gemini_part(part) } +
|
|
70
|
+
shape_gemini_parts(response)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# A thought or a signature alone is a part with no data, which
|
|
74
|
+
# Gemini refuses.
|
|
75
|
+
def shape_gemini_data(part, signed)
|
|
76
|
+
if (data = shape_gemini_field(part, 'inlineData', 'inline_data'))
|
|
77
|
+
return shape_inline(data['data'], mime_type: data['mimeType'] || data['mime_type'], signed:)
|
|
78
|
+
end
|
|
79
|
+
if (file = shape_gemini_field(part, 'fileData', 'file_data'))
|
|
80
|
+
return shape_file(mime_type: file['mimeType'] || file['mime_type'])
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
spec = if part['thought']
|
|
84
|
+
shape_text(nil, kind: :thinking, signed:)
|
|
85
|
+
else
|
|
86
|
+
shape_other(part.keys.find { |key| !PART_MODIFIERS.include?(key) }, signed:)
|
|
87
|
+
end
|
|
88
|
+
spec.without_data = !part.keys.intersect?(DATA_FIELDS)
|
|
89
|
+
spec
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def shape_gemini_field(part, *keys)
|
|
93
|
+
keys.map { |key| part[key] }.find { |value| value.is_a?(Hash) }
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def shape_gemini_tools(tool)
|
|
97
|
+
shape_hash(tool).flat_map do |key, value|
|
|
98
|
+
next [key] unless FUNCTION_DECLARATIONS.include?(key)
|
|
99
|
+
|
|
100
|
+
shape_list(value).map { |declaration| shape_hash(declaration)['name'] }
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def shape_gemini_thinking(request)
|
|
105
|
+
config = shape_hash(request['generationConfig'] || request['generation_config'])
|
|
106
|
+
shape_settings(config['thinkingConfig'] || config['thinking_config'], *THINKING_SETTINGS)
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
@@ -28,6 +28,7 @@ module RubyLLM
|
|
|
28
28
|
output_tokens: extract_output_tokens(data),
|
|
29
29
|
cache_read_tokens: data.dig('usageMetadata', 'cachedContentTokenCount'),
|
|
30
30
|
thinking_tokens: data.dig('usageMetadata', 'thoughtsTokenCount'),
|
|
31
|
+
server_tool_use: parse_server_tool_use(data),
|
|
31
32
|
finish_reason: normalize_finish_reason(
|
|
32
33
|
data.dig('candidates', 0, 'finishReason') || data.dig('promptFeedback', 'blockReason')
|
|
33
34
|
),
|
|
@@ -63,7 +64,7 @@ module RubyLLM
|
|
|
63
64
|
|
|
64
65
|
{
|
|
65
66
|
server_tool_calls: calls,
|
|
66
|
-
raw_content: @saw_server_part ? @stream_parts : nil
|
|
67
|
+
raw_content: @saw_server_part ? without_search_suggestions(@stream_parts) : nil
|
|
67
68
|
}
|
|
68
69
|
end
|
|
69
70
|
|