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
|
@@ -31,7 +31,11 @@ module RubyLLM
|
|
|
31
31
|
|
|
32
32
|
usage = parse_conversation_usage(data['usage'] || {}).transform_keys(&:to_s)
|
|
33
33
|
attachments.each_with_index.map do |attachment, index|
|
|
34
|
-
bytes =
|
|
34
|
+
bytes = if attachment.provider_file?
|
|
35
|
+
@provider.download_file(attachment.provider_file_id)
|
|
36
|
+
else
|
|
37
|
+
attachment.content
|
|
38
|
+
end
|
|
35
39
|
Image.new(data: Base64.strict_encode64(bytes), mime_type: RubyLLM::Files::MimeType.for(StringIO.new(bytes)),
|
|
36
40
|
model: model, usage: index.zero? ? usage : {})
|
|
37
41
|
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyLLM
|
|
4
|
+
module Protocols
|
|
5
|
+
module Mistral
|
|
6
|
+
class Conversations
|
|
7
|
+
# Describes Conversations requests as RequestShape objects. Each
|
|
8
|
+
# input entry is a turn, messages carry Chat Completions content, and
|
|
9
|
+
# a function result answers its call by id.
|
|
10
|
+
module RequestShapes
|
|
11
|
+
def parse_request_shape(payload)
|
|
12
|
+
inputs = payload['inputs']
|
|
13
|
+
return unless inputs.is_a?(Array)
|
|
14
|
+
|
|
15
|
+
turns = inputs.each_with_index.map { |entry, index| shape_conversation_turn(shape_hash(entry), index) }
|
|
16
|
+
shape_request(turns, payload:, instructions: shape_instructions(payload['instructions']),
|
|
17
|
+
tool_names: shape_list(payload['tools']).map { |tool| shape_function_name(tool) },
|
|
18
|
+
thinking_settings: shape_settings(payload['completion_args'], 'reasoning_effort'))
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
def shape_conversation_turn(entry, index)
|
|
24
|
+
role = entry['role'] || entry['type']
|
|
25
|
+
case entry['type']
|
|
26
|
+
when 'message.input'
|
|
27
|
+
speaker = ChatCompletions::RequestShapes::SPEAKERS.fetch(entry['role'], :user)
|
|
28
|
+
shape_turn(index, role, speaker, shape_content(entry['content']))
|
|
29
|
+
when 'message.output' then shape_turn(index, role, :model, shape_content(entry['content']))
|
|
30
|
+
when 'function.result'
|
|
31
|
+
shape_turn(index, role, :tool, shape_tool_result(entry['result'], id: entry['tool_call_id']))
|
|
32
|
+
else shape_turn(index, role, :model, [shape_conversation_step(entry)])
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def shape_conversation_step(entry)
|
|
37
|
+
return shape_other(entry['type']) unless entry['type'] == 'function.call'
|
|
38
|
+
|
|
39
|
+
shape_tool_call(entry['name'], entry['arguments'], id: entry['tool_call_id'])
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -55,9 +55,10 @@ module RubyLLM
|
|
|
55
55
|
end.to_h
|
|
56
56
|
calls = messages.flat_map { |message| Array(message['tool_calls']) }
|
|
57
57
|
pending = multi_pending_calls(calls, results, raw:)
|
|
58
|
+
attachments = content[:attachments] + parse_multi_images(calls, results)
|
|
58
59
|
usage = data['usage'] || {}
|
|
59
60
|
Message.new(role: :assistant, content: content[:text], thinking: Thinking.build(text: content[:thinking]),
|
|
60
|
-
attachments:
|
|
61
|
+
attachments: attachments.uniq(&:source), citations: content[:citations],
|
|
61
62
|
tool_calls: parse_tool_calls(pending, response: raw, finish_reason: :tool_calls),
|
|
62
63
|
server_tool_calls: parse_multi_steps(calls, results), raw_content: messages,
|
|
63
64
|
input_tokens: input_tokens(usage), output_tokens: output_tokens(usage),
|
|
@@ -65,6 +66,15 @@ module RubyLLM
|
|
|
65
66
|
finish_reason: normalize_finish_reason(data.dig('choices', 0, 'finish_reason')))
|
|
66
67
|
end
|
|
67
68
|
|
|
69
|
+
def parse_multi_images(calls, results)
|
|
70
|
+
calls.filter_map do |call|
|
|
71
|
+
result = results[call['id']]
|
|
72
|
+
next unless result && call.dig('metadata', 'tool_type') == 'image'
|
|
73
|
+
|
|
74
|
+
parse_generated_image(result['content'])
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
68
78
|
def parse_multi_steps(calls, results)
|
|
69
79
|
calls.filter_map do |call|
|
|
70
80
|
result = results[call['id']]
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyLLM
|
|
4
|
+
module Protocols
|
|
5
|
+
module OpenAI
|
|
6
|
+
# OpenAI Decisions: typed questions answered with probabilities.
|
|
7
|
+
class Decisions < Protocol
|
|
8
|
+
TYPES = { probability: 'predicate', choice: 'choice', score: 'score' }.freeze
|
|
9
|
+
OUTCOMES = { 'yes' => 'Yes', 'true' => 'Yes', 'no' => 'No', 'false' => 'No' }.freeze
|
|
10
|
+
|
|
11
|
+
def judgment_url
|
|
12
|
+
'decisions'
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def render_judgment_payload(input, questions:, model:, with: [], provider_options: {})
|
|
16
|
+
reserved = provider_options.keys.map(&:to_s) & %w[model input questions]
|
|
17
|
+
unless reserved.empty?
|
|
18
|
+
raise ArgumentError, "Use the judgment arguments instead of provider_options for #{reserved.join(', ')}"
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
{
|
|
22
|
+
model:,
|
|
23
|
+
input: render_input(input, with),
|
|
24
|
+
questions: questions.values.map { |question| render_question(question) }
|
|
25
|
+
}.merge(provider_options)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def parse_judgment_response(response, questions:)
|
|
29
|
+
body = response.body
|
|
30
|
+
answers = questions.values.zip(body.fetch('answers')).to_h do |question, answer|
|
|
31
|
+
[question.name, parse_answer(answer, question)]
|
|
32
|
+
end
|
|
33
|
+
RubyLLM::Judgment.new(answers:, model: body.fetch('model'), raw: response, model_info: @model,
|
|
34
|
+
tokens: parse_tokens(body['usage']))
|
|
35
|
+
rescue KeyError, NoMethodError, TypeError => e
|
|
36
|
+
raise Error.new("OpenAI Decisions returned an invalid judgment: #{e.message}", response:)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
def render_input(input, attachments)
|
|
42
|
+
text = describe(input)
|
|
43
|
+
return text if attachments.empty?
|
|
44
|
+
|
|
45
|
+
content = attachments.map { |attachment| render_image(attachment) }
|
|
46
|
+
content.unshift({ type: 'input_text', text: }) unless text.nil? || text.empty?
|
|
47
|
+
[{ type: 'message', role: 'user', content: }]
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def render_image(image)
|
|
51
|
+
raise UnsupportedAttachmentError, image.mime_type unless image.image?
|
|
52
|
+
return { type: 'input_image', file_id: image.provider_file_id } if image.provider_file?
|
|
53
|
+
|
|
54
|
+
Responses::Media.format_image(image, image_url: image.for_llm,
|
|
55
|
+
original_detail: @provider.original_image_detail?)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def render_question(question)
|
|
59
|
+
question_payload = { type: TYPES.fetch(question.type), name: question.name.to_s,
|
|
60
|
+
instructions: render_instructions(question) }.compact
|
|
61
|
+
|
|
62
|
+
case question.type
|
|
63
|
+
when :choice then question_payload.merge(choices: render_choices(question))
|
|
64
|
+
when :score then question_payload.merge(levels: render_levels(question))
|
|
65
|
+
else question_payload
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def render_instructions(question)
|
|
70
|
+
lines = [describe(question.instructions)]
|
|
71
|
+
if question.type == :probability && question.criteria
|
|
72
|
+
question.criteria.each do |outcome, description|
|
|
73
|
+
lines << "#{OUTCOMES.fetch(outcome.to_s)}: #{describe(description)}" unless description.nil?
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
instructions = lines.compact.join("\n")
|
|
77
|
+
instructions unless instructions.empty?
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def render_choices(question)
|
|
81
|
+
question.criteria.map do |value, description|
|
|
82
|
+
{ value: value.to_s, description: describe(description) }.compact
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def render_levels(question)
|
|
87
|
+
question.criteria.each_with_index.map do |description, index|
|
|
88
|
+
{ label: index.to_s, description: describe(description) }
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def describe(value)
|
|
93
|
+
value.nil? || value.is_a?(String) ? value : JSON.generate(value)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def parse_answer(answer, question)
|
|
97
|
+
case question.type
|
|
98
|
+
when :probability then RubyLLM::Probability.new(probability: answer.fetch('probability'))
|
|
99
|
+
when :choice then parse_choice(answer, question)
|
|
100
|
+
when :score then parse_score(answer, question)
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def parse_choice(answer, question)
|
|
105
|
+
options = question.criteria.keys.to_h { |key| [key.to_s, key] }
|
|
106
|
+
RubyLLM::Choice.new(
|
|
107
|
+
choice: options.fetch(answer.fetch('choice')),
|
|
108
|
+
probabilities: parse_distribution(answer.fetch('probabilities'), 'value', options),
|
|
109
|
+
confidence: answer.fetch('confidence')
|
|
110
|
+
)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def parse_score(answer, question)
|
|
114
|
+
indexes = question.criteria.each_index.to_h { |index| [index.to_s, index] }
|
|
115
|
+
RubyLLM::Score.new(
|
|
116
|
+
score: answer.fetch('score'), levels: question.criteria,
|
|
117
|
+
probabilities: parse_distribution(answer.fetch('probabilities'), 'label', indexes),
|
|
118
|
+
confidence: answer.fetch('confidence')
|
|
119
|
+
)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def parse_distribution(entries, key, names)
|
|
123
|
+
entries.to_h { |entry| [names.fetch(entry.fetch(key)), entry.fetch('probability')] }
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def parse_tokens(usage)
|
|
127
|
+
tokens = Responses::Chat.parse_usage(usage || {})
|
|
128
|
+
Tokens.new(input: tokens[:input_tokens], output: tokens[:output_tokens],
|
|
129
|
+
cache_read: tokens[:cache_read_tokens], cache_write: tokens[:cache_write_tokens],
|
|
130
|
+
thinking: tokens[:thinking_tokens])
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
@@ -5,18 +5,11 @@ module RubyLLM
|
|
|
5
5
|
module OpenAI
|
|
6
6
|
# OpenAI Files API.
|
|
7
7
|
class Files < Protocols::Files
|
|
8
|
-
UPLOAD_PURPOSES = %w[assistants batch fine-tune vision user_data evals].freeze
|
|
9
|
-
|
|
10
8
|
private
|
|
11
9
|
|
|
12
10
|
# rubocop:disable-next Lint/UnusedMethodArgument
|
|
13
11
|
def render_upload_payload(attachment, purpose: nil, expires_in: nil, visibility: nil,
|
|
14
12
|
display_name: nil, uri: nil, content_type: nil)
|
|
15
|
-
unless purpose
|
|
16
|
-
raise ArgumentError, "#{@provider.name} file uploads require purpose: " \
|
|
17
|
-
"#{UPLOAD_PURPOSES.join(', ')}"
|
|
18
|
-
end
|
|
19
|
-
|
|
20
13
|
multipart_payload(attachment, purpose:, expires_after: expires_after(expires_in))
|
|
21
14
|
end
|
|
22
15
|
|
|
@@ -34,12 +34,7 @@ module RubyLLM
|
|
|
34
34
|
|
|
35
35
|
def batch_results(id)
|
|
36
36
|
data = batch_data(id)
|
|
37
|
-
|
|
38
|
-
unless results.map(&:first).uniq.size == results.size
|
|
39
|
-
raise Error, 'OpenRouter returned duplicate batch request IDs'
|
|
40
|
-
end
|
|
41
|
-
|
|
42
|
-
results
|
|
37
|
+
Array(data['results']).map { |row| parse_batch_result(row, data:) }
|
|
43
38
|
end
|
|
44
39
|
|
|
45
40
|
private
|
|
@@ -125,8 +120,18 @@ module RubyLLM
|
|
|
125
120
|
return [index, nil, batch_failure(custom_id, batch_error_message(row))]
|
|
126
121
|
end
|
|
127
122
|
|
|
128
|
-
|
|
129
|
-
|
|
123
|
+
body = response.fetch('body')
|
|
124
|
+
endpoint = data.fetch('endpoint')
|
|
125
|
+
if endpoint == '/v1/embeddings' && !embedding_positions?(body)
|
|
126
|
+
return [index, nil, batch_failure(custom_id, 'Invalid or duplicate embedding record positions')]
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
[index, parse_batch_body(body, endpoint:, model: data.fetch('model'), shape:)]
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def embedding_positions?(body)
|
|
133
|
+
positions = body.fetch('data').map { |row| row['index'] }
|
|
134
|
+
positions.all?(Integer) && positions.sort == (0...positions.size).to_a
|
|
130
135
|
end
|
|
131
136
|
|
|
132
137
|
def parse_batch_body(body, endpoint:, model:, shape:)
|
|
@@ -30,7 +30,11 @@ module RubyLLM
|
|
|
30
30
|
end
|
|
31
31
|
|
|
32
32
|
def parse_usage(usage)
|
|
33
|
-
super.merge(reported_cost: reported_cost(usage)
|
|
33
|
+
super.merge(reported_cost: reported_cost(usage))
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def parse_server_tool_use(response)
|
|
37
|
+
server_tool_use(response['usage'].to_h)
|
|
34
38
|
end
|
|
35
39
|
|
|
36
40
|
def reported_cost(usage)
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyLLM
|
|
4
|
+
module Protocols
|
|
5
|
+
module Perplexity
|
|
6
|
+
# Perplexity's Agent API, a dialect of the Responses API. A request names
|
|
7
|
+
# either a preset, which bundles a model, search settings, and
|
|
8
|
+
# instructions, or a model in provider/model form. The retired Sonar
|
|
9
|
+
# model ids run the presets Perplexity recommends in their place.
|
|
10
|
+
class Agent < Responses
|
|
11
|
+
PRESETS = %w[fast low medium high xhigh wide-research].freeze
|
|
12
|
+
SONAR_PRESETS = {
|
|
13
|
+
'sonar' => 'fast',
|
|
14
|
+
'sonar-pro' => 'low',
|
|
15
|
+
'sonar-reasoning-pro' => 'medium',
|
|
16
|
+
'sonar-deep-research' => 'high'
|
|
17
|
+
}.freeze
|
|
18
|
+
SERVER_TOOL_USAGE_NAMES = { 'search_web' => 'web_search', 'fetch_url' => 'web_fetch' }.freeze
|
|
19
|
+
|
|
20
|
+
def completion_url
|
|
21
|
+
@provider.agent_url
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def render_payload(messages, model:, max_output_tokens: nil, **)
|
|
25
|
+
max_output_tokens ||= default_max_output_tokens(model)
|
|
26
|
+
payload = super
|
|
27
|
+
preset = preset_for(model.id)
|
|
28
|
+
return payload unless preset
|
|
29
|
+
|
|
30
|
+
payload.delete(:model)
|
|
31
|
+
payload.merge(preset: preset)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def format_document(document)
|
|
35
|
+
raise UnsupportedAttachmentError, document.mime_type
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def supported_attachment?(attachment)
|
|
39
|
+
attachment.provider_file? || (%i[image text].include?(attachment.type) && super)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
def default_max_output_tokens(model)
|
|
45
|
+
model.max_output_tokens || Anthropic::DEFAULT_MAX_OUTPUT_TOKENS if model.id.start_with?('anthropic/')
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def preset_for(model_id)
|
|
49
|
+
return model_id if PRESETS.include?(model_id)
|
|
50
|
+
|
|
51
|
+
preset = SONAR_PRESETS[model_id]
|
|
52
|
+
if preset
|
|
53
|
+
RubyLLM.deprecator.warn(
|
|
54
|
+
"Perplexity retires Sonar on September 27, 2026. #{model_id} now runs the #{preset} " \
|
|
55
|
+
"Agent API preset; use model: \"#{preset}\" instead."
|
|
56
|
+
)
|
|
57
|
+
end
|
|
58
|
+
preset
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def parse_citations(data, output, content)
|
|
62
|
+
citations = super
|
|
63
|
+
return citations if citations.any?
|
|
64
|
+
|
|
65
|
+
results = output.select { |item| item['type'] == 'search_results' }.flat_map { |item| Array(item['results']) }
|
|
66
|
+
parse_search_results(results)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def parse_usage(usage)
|
|
70
|
+
details = usage['input_tokens_details'] || {}
|
|
71
|
+
details = { 'cache_write_tokens' => details['cache_creation_input_tokens'] }.merge(details)
|
|
72
|
+
|
|
73
|
+
super(usage.merge('input_tokens_details' => details)).merge(reported_cost: usage.dig('cost', 'total_cost'))
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def parse_server_tool_use(response)
|
|
77
|
+
response.dig('usage', 'tool_calls_details').to_h.to_h do |tool, details|
|
|
78
|
+
["#{SERVER_TOOL_USAGE_NAMES.fetch(tool, tool)}_requests", details['invocation']]
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Perplexity sends each function call whole, without argument deltas,
|
|
83
|
+
# and announces its own web searches as function calls whose items
|
|
84
|
+
# finish as search_results. Only a finished function_call item is a
|
|
85
|
+
# call for the application to run.
|
|
86
|
+
def build_item_added_chunk(data)
|
|
87
|
+
data.dig('item', 'type') == 'function_call' ? chunk : super
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def build_item_done_chunk(data)
|
|
91
|
+
item = data['item']
|
|
92
|
+
return super unless item['type'] == 'function_call'
|
|
93
|
+
|
|
94
|
+
chunk tool_calls: {
|
|
95
|
+
data['output_index'] => ToolCall.new(id: item['call_id'], name: item['name'],
|
|
96
|
+
arguments: +item['arguments'].to_s)
|
|
97
|
+
}
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Perplexity rejects its own search_results and fetch_url_results
|
|
101
|
+
# items as input, so a replayed turn keeps only messages, reasoning,
|
|
102
|
+
# and function calls.
|
|
103
|
+
def format_assistant_items(msg)
|
|
104
|
+
super.select do |item|
|
|
105
|
+
type = item[:type] || item['type']
|
|
106
|
+
type.nil? || Responses::Chat::CLIENT_OUTPUT_ITEM_TYPES.include?(type)
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def supports_provider_file_references?
|
|
111
|
+
false
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
@@ -5,53 +5,12 @@ module RubyLLM
|
|
|
5
5
|
module Perplexity
|
|
6
6
|
# Perplexity Router's Chat Completions dialect.
|
|
7
7
|
class Router < ChatCompletions
|
|
8
|
-
REJECTED_OPTIONS = %i[seed logit_bias top_logprobs functions function_call modalities audio prediction
|
|
9
|
-
web_search_options moderation verbosity].freeze
|
|
10
|
-
DEFAULT_OPTIONS = { n: 1, logprobs: false, store: false, presence_penalty: 0, frequency_penalty: 0 }.freeze
|
|
11
|
-
private_constant :REJECTED_OPTIONS, :DEFAULT_OPTIONS
|
|
12
|
-
|
|
13
8
|
def completion_url
|
|
14
9
|
@provider.router_url('chat/completions')
|
|
15
10
|
end
|
|
16
11
|
|
|
17
|
-
def
|
|
18
|
-
super.
|
|
19
|
-
validate_router_options(payload)
|
|
20
|
-
validate_router_tools(payload[:tools])
|
|
21
|
-
end
|
|
22
|
-
end
|
|
23
|
-
|
|
24
|
-
def render_payload(messages, schema: nil, **options)
|
|
25
|
-
if schema && schema[:strict] == false
|
|
26
|
-
raise ArgumentError, 'Perplexity Router requires strict structured output'
|
|
27
|
-
end
|
|
28
|
-
|
|
29
|
-
super(messages, schema: schema&.merge(strict: true), **options)
|
|
30
|
-
end
|
|
31
|
-
|
|
32
|
-
def format_audio(audio)
|
|
33
|
-
raise UnsupportedAttachmentError, audio.mime_type unless %w[mp3 wav].include?(audio.format)
|
|
34
|
-
|
|
35
|
-
super
|
|
36
|
-
end
|
|
37
|
-
|
|
38
|
-
private
|
|
39
|
-
|
|
40
|
-
def validate_router_options(payload)
|
|
41
|
-
unsupported = payload.keys & REJECTED_OPTIONS
|
|
42
|
-
unsupported.concat(DEFAULT_OPTIONS.keys.select do |key|
|
|
43
|
-
payload.key?(key) && payload[key] != DEFAULT_OPTIONS[key]
|
|
44
|
-
end)
|
|
45
|
-
unsupported << :include_obfuscation if payload.dig(:stream_options, :include_obfuscation)
|
|
46
|
-
return if unsupported.empty?
|
|
47
|
-
|
|
48
|
-
raise ArgumentError, "Perplexity Router does not support these options: #{unsupported.join(', ')}"
|
|
49
|
-
end
|
|
50
|
-
|
|
51
|
-
def validate_router_tools(tools)
|
|
52
|
-
return if Array(tools).all? { |tool| tool.dig(:function, :description).is_a?(String) }
|
|
53
|
-
|
|
54
|
-
raise ArgumentError, 'Perplexity Router function tools require a description'
|
|
12
|
+
def render_payload(messages, schema: nil, **)
|
|
13
|
+
super(messages, schema: schema && { strict: true }.merge(schema), **)
|
|
55
14
|
end
|
|
56
15
|
end
|
|
57
16
|
end
|
|
@@ -10,7 +10,6 @@ module RubyLLM
|
|
|
10
10
|
end
|
|
11
11
|
|
|
12
12
|
OPENAI_INLINE_FILE_LIMIT = 50 * 1024 * 1024
|
|
13
|
-
OPENAI_FILE_UPLOAD_LIMIT = 512 * 1024 * 1024
|
|
14
13
|
PROMPT_CACHE_OPTIONS = %i[key ttl mode retention].freeze
|
|
15
14
|
|
|
16
15
|
module_function
|
|
@@ -69,11 +68,12 @@ module RubyLLM
|
|
|
69
68
|
),
|
|
70
69
|
tool_calls: parse_pending_tool_calls(output, response: raw, finish_reason: finish_reason),
|
|
71
70
|
server_tool_calls: server_tool_calls,
|
|
72
|
-
raw_content:
|
|
71
|
+
raw_content: keep_raw_output?(output, server_tool_calls) ? output : nil,
|
|
73
72
|
model: data['model'],
|
|
74
73
|
raw: raw,
|
|
75
74
|
finish_reason: finish_reason,
|
|
76
|
-
**parse_usage(data['usage'] || {})
|
|
75
|
+
**parse_usage(data['usage'] || {}),
|
|
76
|
+
server_tool_use: parse_server_tool_use(data)
|
|
77
77
|
)
|
|
78
78
|
end
|
|
79
79
|
|
|
@@ -105,6 +105,26 @@ module RubyLLM
|
|
|
105
105
|
end
|
|
106
106
|
|
|
107
107
|
CLIENT_OUTPUT_ITEM_TYPES = %w[message reasoning function_call].freeze
|
|
108
|
+
TOOL_SEARCH_ITEM_TYPES = %w[tool_search_call tool_search_output].freeze
|
|
109
|
+
|
|
110
|
+
PORTABLE_OUTPUT_ITEM_TYPES = (%w[message function_call] + TOOL_SEARCH_ITEM_TYPES).freeze
|
|
111
|
+
|
|
112
|
+
def keep_raw_output?(output, server_tool_calls)
|
|
113
|
+
server_tool_calls.any? || output.any? { |item| namespaced_call?(item) }
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def namespaced_call?(item)
|
|
117
|
+
item['type'] == 'function_call' && !item['namespace'].nil?
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def portable_raw_content(message)
|
|
121
|
+
items = message.raw_content
|
|
122
|
+
return unless items.is_a?(Array)
|
|
123
|
+
return unless items.any? { |item| TOOL_SEARCH_ITEM_TYPES.include?(item['type']) || namespaced_call?(item) }
|
|
124
|
+
|
|
125
|
+
kept = items.reject { |item| item['type'] == 'reasoning' }
|
|
126
|
+
kept if kept.all? { |item| PORTABLE_OUTPUT_ITEM_TYPES.include?(item['type']) }
|
|
127
|
+
end
|
|
108
128
|
|
|
109
129
|
# Output items beyond text, reasoning, and function calls record
|
|
110
130
|
# provider-executed tool steps (web_search_call, code_interpreter_call,
|
|
@@ -251,6 +271,14 @@ module RubyLLM
|
|
|
251
271
|
}
|
|
252
272
|
end
|
|
253
273
|
|
|
274
|
+
# Searches are billed by tool_usage, not by the web_search_call items
|
|
275
|
+
# in the output, which can each run several.
|
|
276
|
+
def parse_server_tool_use(response)
|
|
277
|
+
response['tool_usage'].to_h.filter_map do |tool, usage|
|
|
278
|
+
["#{tool}_requests", usage['num_requests']] if usage['num_requests']
|
|
279
|
+
end.to_h
|
|
280
|
+
end
|
|
281
|
+
|
|
254
282
|
def schema_format(schema)
|
|
255
283
|
{
|
|
256
284
|
type: 'json_schema',
|
|
@@ -260,32 +288,42 @@ module RubyLLM
|
|
|
260
288
|
}
|
|
261
289
|
end
|
|
262
290
|
|
|
263
|
-
#
|
|
264
|
-
#
|
|
265
|
-
#
|
|
291
|
+
# The +instructions+ parameter is a plain string and cannot carry a
|
|
292
|
+
# breakpoint marker or a file. When any system message needs one, every
|
|
293
|
+
# system message rides along as an input item, so their order holds.
|
|
266
294
|
def format_instructions(messages, caching: nil)
|
|
267
|
-
|
|
268
|
-
|
|
295
|
+
system = messages.select { |msg| msg.role == :system }
|
|
296
|
+
return if system.empty? || system_input_items?(system, caching:)
|
|
269
297
|
|
|
270
|
-
|
|
298
|
+
system.map { |msg| msg.content.to_s }.join("\n\n")
|
|
271
299
|
end
|
|
272
300
|
|
|
273
301
|
def format_input(messages, caching: nil)
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
302
|
+
system, conversation = messages.partition { |msg| msg.role == :system }
|
|
303
|
+
system_items = if system_input_items?(system, caching:)
|
|
304
|
+
system.flat_map { |msg| [format_item(msg, caching:)].flatten(1) }
|
|
305
|
+
else
|
|
306
|
+
[]
|
|
307
|
+
end
|
|
277
308
|
|
|
309
|
+
system_items + format_conversation(conversation, caching:)
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
def format_conversation(messages, caching: nil)
|
|
313
|
+
messages.each_with_object([]) do |message, input|
|
|
278
314
|
raw = message.raw_content
|
|
279
315
|
if raw.is_a?(Hash) && raw['object'] == 'response.compaction'
|
|
280
|
-
input.replace(
|
|
316
|
+
input.replace(raw.fetch('output'))
|
|
281
317
|
else
|
|
282
|
-
|
|
283
|
-
system_items.concat(items) if message.role == :system
|
|
284
|
-
input.concat(items)
|
|
318
|
+
input.concat([format_item(message, caching:)].flatten(1))
|
|
285
319
|
end
|
|
286
320
|
end
|
|
287
321
|
end
|
|
288
322
|
|
|
323
|
+
def system_input_items?(messages, caching: nil)
|
|
324
|
+
messages.any? { |msg| system_input_item?(msg, caching:) }
|
|
325
|
+
end
|
|
326
|
+
|
|
289
327
|
def system_input_item?(msg, caching: nil)
|
|
290
328
|
msg.role == :system && ((caching != false && msg.cache_until_here?) || msg.attachments.any?)
|
|
291
329
|
end
|
|
@@ -333,7 +371,7 @@ module RubyLLM
|
|
|
333
371
|
|
|
334
372
|
if msg.attachments.any?
|
|
335
373
|
parts = [{ type: 'input_text', text: "Attachments from tool call #{msg.tool_call_id}:" }]
|
|
336
|
-
parts.concat(
|
|
374
|
+
parts.concat(format_content(nil, msg.attachments))
|
|
337
375
|
items << { role: 'user', content: parts }
|
|
338
376
|
end
|
|
339
377
|
|
|
@@ -346,7 +384,7 @@ module RubyLLM
|
|
|
346
384
|
return msg.raw_content if msg.raw_content
|
|
347
385
|
|
|
348
386
|
items = []
|
|
349
|
-
items << format_reasoning_item(msg.thinking) if msg
|
|
387
|
+
items << format_reasoning_item(msg.thinking) if own_signature(msg)
|
|
350
388
|
items << { role: 'assistant', content: format_output_content(msg) } unless empty_content?(msg.content)
|
|
351
389
|
items.concat(format_function_call_items(msg.tool_calls)) if msg.tool_call?
|
|
352
390
|
items
|
|
@@ -449,10 +487,6 @@ module RubyLLM
|
|
|
449
487
|
OPENAI_INLINE_FILE_LIMIT
|
|
450
488
|
end
|
|
451
489
|
|
|
452
|
-
def provider_file_upload_limit
|
|
453
|
-
OPENAI_FILE_UPLOAD_LIMIT
|
|
454
|
-
end
|
|
455
|
-
|
|
456
490
|
def provider_file_attachable?(attachment)
|
|
457
491
|
attachment.pdf? || attachment.document?
|
|
458
492
|
end
|
|
@@ -7,6 +7,10 @@ module RubyLLM
|
|
|
7
7
|
module Media
|
|
8
8
|
module_function
|
|
9
9
|
|
|
10
|
+
def supported_attachment?(attachment)
|
|
11
|
+
attachment.provider_file? || %i[image pdf document text].include?(attachment.type)
|
|
12
|
+
end
|
|
13
|
+
|
|
10
14
|
def format_content(content, attachments = [])
|
|
11
15
|
return content if attachments.empty?
|
|
12
16
|
|
|
@@ -17,6 +21,8 @@ module RubyLLM
|
|
|
17
21
|
end
|
|
18
22
|
|
|
19
23
|
def format_attachment(attachment)
|
|
24
|
+
raise UnsupportedAttachmentError, attachment.mime_type unless supported_attachment?(attachment)
|
|
25
|
+
|
|
20
26
|
return format_provider_file(attachment) if attachment.provider_file?
|
|
21
27
|
|
|
22
28
|
case attachment.type
|
|
@@ -26,16 +32,14 @@ module RubyLLM
|
|
|
26
32
|
format_document(attachment)
|
|
27
33
|
when :text
|
|
28
34
|
{ type: 'input_text', text: attachment.for_llm }
|
|
29
|
-
else
|
|
30
|
-
raise UnsupportedAttachmentError, attachment.mime_type
|
|
31
35
|
end
|
|
32
36
|
end
|
|
33
37
|
|
|
34
|
-
def format_image(image)
|
|
35
|
-
{
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
38
|
+
def format_image(image, image_url: image.url_or_data_uri, original_detail: @provider&.original_image_detail?)
|
|
39
|
+
part = { type: 'input_image', image_url: }
|
|
40
|
+
return part unless image.resolution
|
|
41
|
+
|
|
42
|
+
part.merge(detail: ChatCompletions::Media.image_detail(image.resolution, original_detail:))
|
|
39
43
|
end
|
|
40
44
|
|
|
41
45
|
# The Responses API extracts text from documents, presentations, and
|