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,190 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyLLM
|
|
4
|
+
class Protocol
|
|
5
|
+
module RequestShapes
|
|
6
|
+
# Finds what a provider would refuse in the turns a protocol read: parts
|
|
7
|
+
# with no data, empty turns, tool rounds whose calls and results do not
|
|
8
|
+
# pair up, and results that answer no call, plus the rules a protocol
|
|
9
|
+
# opts into. +pairing+ is how the provider matches results to calls:
|
|
10
|
+
# +:id+, or +:position+, which compares tool names in order. The rules
|
|
11
|
+
# are +:signed_steps+, where the first call of each step in the current
|
|
12
|
+
# turn needs a signature; +:signed_thinking+, where every thinking part
|
|
13
|
+
# does; and +:alternating_roles+, where user and model turns alternate,
|
|
14
|
+
# starting with the user.
|
|
15
|
+
class Analysis # :nodoc: all
|
|
16
|
+
KEPT_FIRST_TURNS = 10
|
|
17
|
+
KEPT_LAST_TURNS = 50
|
|
18
|
+
|
|
19
|
+
Round = Struct.new(:turn, :calls, :results)
|
|
20
|
+
|
|
21
|
+
def initialize(turns, pairing:, rules:)
|
|
22
|
+
@turns = turns
|
|
23
|
+
@pairing = pairing
|
|
24
|
+
@rules = rules
|
|
25
|
+
name_results
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def kept_turns
|
|
29
|
+
return @turns if @turns.size <= KEPT_FIRST_TURNS + KEPT_LAST_TURNS
|
|
30
|
+
|
|
31
|
+
@turns.first(KEPT_FIRST_TURNS) + @turns.last(KEPT_LAST_TURNS)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# The request is the model call after every round of the current
|
|
35
|
+
# turn, which starts after the last user turn.
|
|
36
|
+
def step
|
|
37
|
+
rounds.count { |round| round.turn.index > last_user_turn.index } + 1 if last_user_turn
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def tool_rounds
|
|
41
|
+
kept = kept_turns.to_set(&:index)
|
|
42
|
+
rounds.select { |round| kept.include?(round.turn.index) }.map do |round|
|
|
43
|
+
RequestShape::ToolRound.new(turn: round.turn.index, calls: round.calls.size,
|
|
44
|
+
results: round.results.size, paired: paired?(round))
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def problems
|
|
49
|
+
found = [*parts_without_data, *empty_turns, *unpaired_rounds, *unmatched_results, *unsigned_calls,
|
|
50
|
+
*unsigned_thinking, *roles_out_of_order]
|
|
51
|
+
found.each_with_index.sort_by { |problem, order| [problem.turn, problem.part || -1, order] }.map(&:first)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
def located_parts(turns = @turns)
|
|
57
|
+
turns.flat_map { |turn| turn.parts.each_with_index.map { |part, position| [turn, position, part] } }
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def last_user_turn
|
|
61
|
+
@turns.reverse.find { |turn| turn.speaker == :user }
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def name_results
|
|
65
|
+
return unless @pairing == :id
|
|
66
|
+
|
|
67
|
+
names = {}
|
|
68
|
+
located_parts.each do |_, _, part|
|
|
69
|
+
names[part.call_id] = part.name if part.call? && part.call_id
|
|
70
|
+
part.name ||= names[part.result_id] if part.result?
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Consecutive model turns make one round, answered by the results in
|
|
75
|
+
# the turns that follow them up to the next model turn.
|
|
76
|
+
def rounds
|
|
77
|
+
@rounds ||= runs(@turns).push([]).each_cons(2).filter_map { |run, answers| round(run, answers) }
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def round(run, answers)
|
|
81
|
+
calls = run.flat_map(&:parts).select(&:call?)
|
|
82
|
+
Round.new(run.first, calls, answers.flat_map(&:parts).select(&:result?)) if run.first.model? && calls.any?
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def runs(turns)
|
|
86
|
+
turns.slice_when { |turn, following| turn.model? != following.model? }.to_a
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def paired?(round)
|
|
90
|
+
calls = round.calls
|
|
91
|
+
results = round.results
|
|
92
|
+
return false unless calls.size == results.size
|
|
93
|
+
return calls.map(&:name) == results.map(&:name) unless @pairing == :id && (calls + results).all? { ids?(_1) }
|
|
94
|
+
|
|
95
|
+
calls.map(&:call_id).tally == results.map(&:result_id).tally
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def ids?(part)
|
|
99
|
+
part.call? ? part.call_id : part.result_id
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def parts_without_data
|
|
103
|
+
located_parts.filter_map do |turn, position, part|
|
|
104
|
+
next unless part.without_data
|
|
105
|
+
|
|
106
|
+
noun = part.kind == :other ? 'part' : "#{part.kind} part"
|
|
107
|
+
problem(:part_without_data, "#{noun} carries no data", turn:, part: position)
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def empty_turns
|
|
112
|
+
@turns.select { |turn| turn.parts.empty? }.map { |turn| problem(:empty_turn, 'turn has no parts', turn:) }
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def unpaired_rounds
|
|
116
|
+
rounds.reject { |round| paired?(round) }.map do |round|
|
|
117
|
+
calls = counted(round.calls.size, 'call')
|
|
118
|
+
results = counted(round.results.size, 'result')
|
|
119
|
+
message = if round.calls.size == round.results.size
|
|
120
|
+
"#{calls} and #{results} that do not pair up"
|
|
121
|
+
else
|
|
122
|
+
"#{calls} but #{results}"
|
|
123
|
+
end
|
|
124
|
+
problem(:unpaired_round, message, turn: round.turn)
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# The one place a shape shows an id: a result naming a call the
|
|
129
|
+
# request never made can only be traced by the id it names.
|
|
130
|
+
def unmatched_results
|
|
131
|
+
return [] unless @pairing == :id
|
|
132
|
+
|
|
133
|
+
called = Set.new
|
|
134
|
+
located_parts.filter_map do |turn, position, part|
|
|
135
|
+
called << part.call_id if part.call? && part.call_id
|
|
136
|
+
next unless part.result? && !called.include?(part.result_id)
|
|
137
|
+
|
|
138
|
+
message = 'result answers no call in the request'
|
|
139
|
+
problem(:unmatched_result, message, turn:, part: position, call_id: part.result_id)
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def unsigned_calls
|
|
144
|
+
return [] unless @rules.include?(:signed_steps)
|
|
145
|
+
|
|
146
|
+
start = last_user_turn ? @turns.index(last_user_turn) + 1 : 0
|
|
147
|
+
runs(@turns.drop(start)).select { |run| run.first.model? }.filter_map do |run|
|
|
148
|
+
turn, position, call = located_parts(run).find { |_, _, part| part.call? }
|
|
149
|
+
next if call.nil? || call.signed
|
|
150
|
+
|
|
151
|
+
message = 'first call of a step in the current turn has no signature'
|
|
152
|
+
problem(:unsigned_call, message, turn:, part: position)
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def unsigned_thinking
|
|
157
|
+
return [] unless @rules.include?(:signed_thinking)
|
|
158
|
+
|
|
159
|
+
located_parts.filter_map do |turn, position, part|
|
|
160
|
+
next unless part.kind == :thinking && !part.signed
|
|
161
|
+
|
|
162
|
+
problem(:unsigned_thinking, 'thinking part has no signature', turn:, part: position)
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def roles_out_of_order
|
|
167
|
+
return [] unless @rules.include?(:alternating_roles)
|
|
168
|
+
|
|
169
|
+
turns = @turns.reject { |turn| turn.speaker == :system }
|
|
170
|
+
problems = turns.each_cons(2).filter_map do |turn, following|
|
|
171
|
+
next unless turn.model? == following.model?
|
|
172
|
+
|
|
173
|
+
problem(:role_order, "follows another #{following.model? ? 'model' : 'user'} turn", turn: following)
|
|
174
|
+
end
|
|
175
|
+
first = turns.first
|
|
176
|
+
problems.unshift(problem(:role_order, 'conversation starts with a model turn', turn: first)) if first&.model?
|
|
177
|
+
problems
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def problem(kind, message, turn:, part: nil, call_id: nil)
|
|
181
|
+
RequestShape::Problem.new(kind:, message:, turn: turn.index, part:, call_id:)
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def counted(count, noun)
|
|
185
|
+
"#{count} #{noun}#{'s' unless count == 1}"
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
end
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
5
|
+
module RubyLLM
|
|
6
|
+
class Protocol
|
|
7
|
+
# Builds the RequestShape a protocol returns from parse_request_shape. A
|
|
8
|
+
# protocol reads its payload into turns of parts, keeping each piece's
|
|
9
|
+
# size and never its content, and hands them to shape_request with the
|
|
10
|
+
# rules its provider enforces; Analysis then finds the problems.
|
|
11
|
+
module RequestShapes # :nodoc: all
|
|
12
|
+
SETTING_VALUE = /\A[\w.-]{1,40}\z/
|
|
13
|
+
|
|
14
|
+
# A piece of a turn as a protocol reads it, with the ids and flags the
|
|
15
|
+
# analysis needs and a shape never shows.
|
|
16
|
+
PartSpec = Struct.new(:kind, :measure, :unit, :name, :source, :mime_type, :signed, :call_id, :result_id,
|
|
17
|
+
:without_data, keyword_init: true) do
|
|
18
|
+
def call?
|
|
19
|
+
kind == :tool_call
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def result?
|
|
23
|
+
kind == :tool_result
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def to_part
|
|
27
|
+
RequestShape::Part.new(kind:, size: measure, unit:, name:, source:, mime_type:, signed: signed || false)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# A turn as a protocol reads it. Its speaker, +:system+, +:user+,
|
|
32
|
+
# +:model+, or +:tool+, tells the analysis whose turn it is.
|
|
33
|
+
TurnSpec = Struct.new(:index, :role, :speaker, :parts, keyword_init: true) do
|
|
34
|
+
def model?
|
|
35
|
+
speaker == :model
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def to_turn
|
|
39
|
+
RequestShape::Turn.new(index:, role:, parts: parts.map(&:to_part))
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def shape_request(turns, payload:, instructions: [], tool_names: [], thinking_settings: {}, pairing: :id,
|
|
46
|
+
rules: [])
|
|
47
|
+
analysis = Analysis.new(turns, pairing:, rules:)
|
|
48
|
+
kept = analysis.kept_turns
|
|
49
|
+
RequestShape.new(
|
|
50
|
+
provider: @provider&.slug, model: @model&.id, step: analysis.step, payload_keys: payload.keys,
|
|
51
|
+
thinking_settings: thinking_settings, instructions: instructions.map(&:to_part),
|
|
52
|
+
turns: kept.map(&:to_turn), turn_count: turns.size, omitted_turns: turns.size - kept.size,
|
|
53
|
+
tool_names: shape_list(tool_names).filter_map { |name| shape_name(name) },
|
|
54
|
+
tool_rounds: analysis.tool_rounds, problems: analysis.problems
|
|
55
|
+
)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def shape_turn(index, role, speaker, parts)
|
|
59
|
+
TurnSpec.new(index:, role: shape_name(role), speaker:, parts: parts.flatten.compact)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# A turn on the user's side that carries tool results answers a model
|
|
63
|
+
# turn instead of starting a new one.
|
|
64
|
+
def shape_answer(parts)
|
|
65
|
+
parts.flatten.compact.any?(&:result?) ? :tool : :user
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def shape_instructions(text)
|
|
69
|
+
text.nil? ? [] : [shape_text(text)]
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def shape_text(text, kind: :text, signed: false)
|
|
73
|
+
PartSpec.new(kind:, measure: shape_length(text), signed:, without_data: kind == :text && text.nil?)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def shape_summary(summary, signed: false)
|
|
77
|
+
parts = shape_list(summary)
|
|
78
|
+
measure = parts.sum { |part| shape_length(shape_hash(part)['text']).to_i } unless parts.empty?
|
|
79
|
+
PartSpec.new(kind: :thinking, measure:, signed:)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def shape_tool_call(name, arguments, id: nil, signed: false)
|
|
83
|
+
PartSpec.new(kind: :tool_call, name: shape_name(name), measure: shape_length(arguments),
|
|
84
|
+
call_id: shape_name(id), signed:)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# A result made of content blocks counts the characters of its text,
|
|
88
|
+
# and its other blocks, such as images, follow it as parts of their
|
|
89
|
+
# own. The block reads one content block in the protocol's format.
|
|
90
|
+
def shape_tool_result(content, name: nil, id: nil)
|
|
91
|
+
result = PartSpec.new(kind: :tool_result, name: shape_name(name), result_id: shape_name(id))
|
|
92
|
+
unless content.is_a?(Array) && block_given?
|
|
93
|
+
result.measure = shape_length(content)
|
|
94
|
+
return [result]
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
texts, others = content.flat_map { |block| yield shape_hash(block) }.partition { |part| part.kind == :text }
|
|
98
|
+
result.measure = texts.sum { |part| part.measure.to_i }
|
|
99
|
+
[result, *others]
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def shape_other(name, signed: false)
|
|
103
|
+
PartSpec.new(kind: :other, name: shape_name(name), signed:)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def shape_media(kind, source:, size: nil, unit: nil, mime_type: nil, signed: false)
|
|
107
|
+
mime_type = shape_name(mime_type)
|
|
108
|
+
PartSpec.new(kind: kind || shape_media_kind(mime_type), measure: size, unit:, source:, mime_type:, signed:)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def shape_inline(data, kind: nil, mime_type: nil, signed: false)
|
|
112
|
+
shape_media(kind, source: :inline, size: shape_bytes(data), unit: :bytes, mime_type:, signed:)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def shape_file(kind: nil, mime_type: nil)
|
|
116
|
+
shape_media(kind, source: :file, mime_type:)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# A URL field can hold a data URI instead of a link.
|
|
120
|
+
def shape_url(url, kind: nil, mime_type: nil)
|
|
121
|
+
return shape_media(kind, source: :url, mime_type:) unless url.is_a?(String) && url.start_with?('data:')
|
|
122
|
+
|
|
123
|
+
header, data = url.split(',', 2)
|
|
124
|
+
shape_inline(data, kind:, mime_type: header.delete_prefix('data:').split(';').first)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def shape_media_kind(mime_type)
|
|
128
|
+
type = mime_type.to_s
|
|
129
|
+
return :image if Files::MimeType.image?(type)
|
|
130
|
+
return :audio if Files::MimeType.audio?(type)
|
|
131
|
+
return :video if Files::MimeType.video?(type)
|
|
132
|
+
|
|
133
|
+
:document
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def shape_tool_name(tool)
|
|
137
|
+
tool = shape_hash(tool)
|
|
138
|
+
tool['name'] || tool['type']
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Thinking settings are short words and numbers, such as an effort or
|
|
142
|
+
# a budget, so anything else is left out rather than risk content.
|
|
143
|
+
def shape_settings(settings, *names)
|
|
144
|
+
shape_hash(settings).slice(*names).select do |_, value|
|
|
145
|
+
[Integer, Float, TrueClass, FalseClass].any? { |type| value.is_a?(type) } ||
|
|
146
|
+
(value.is_a?(String) && value.match?(SETTING_VALUE))
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def shape_signature?(signature)
|
|
151
|
+
signature.is_a?(String) && !signature.empty?
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def shape_name(value)
|
|
155
|
+
value if value.is_a?(String) && !value.empty?
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def shape_length(value)
|
|
159
|
+
case value
|
|
160
|
+
when nil then nil
|
|
161
|
+
when String then value.length
|
|
162
|
+
else JSON.generate(value).length
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# Base64 carries three bytes in every four characters, and its padding
|
|
167
|
+
# marks the missing bytes of the last group.
|
|
168
|
+
def shape_bytes(data)
|
|
169
|
+
return unless data.is_a?(String)
|
|
170
|
+
|
|
171
|
+
((data.length - data.count("\r\n")) * 3 / 4) - [data.count('='), 2].min
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def shape_hash(value)
|
|
175
|
+
value.is_a?(Hash) ? value : {}
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def shape_list(value)
|
|
179
|
+
value.is_a?(Array) ? value : []
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyLLM
|
|
4
|
+
class Protocol
|
|
5
|
+
# Reuses the provider file an attachment's store recorded in an earlier
|
|
6
|
+
# process. Before a process first reuses a file, the provider confirms
|
|
7
|
+
# it still has it; a file that is missing or expired is uploaded again.
|
|
8
|
+
class StoredUploads # :nodoc:
|
|
9
|
+
FILES = Support::ProcessCache.new(limit: 1024)
|
|
10
|
+
|
|
11
|
+
def self.files
|
|
12
|
+
FILES
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def initialize(provider, store)
|
|
16
|
+
@provider = provider
|
|
17
|
+
@store = store
|
|
18
|
+
@account = provider.account_identity if store
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def fetch
|
|
22
|
+
return yield unless @account
|
|
23
|
+
|
|
24
|
+
stored_file || remember(yield)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def forget(upload)
|
|
28
|
+
return unless @account
|
|
29
|
+
|
|
30
|
+
FILES.delete(key(upload.id))
|
|
31
|
+
@store.forget(upload.id, provider: @provider.slug, account: @account)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def stored_file
|
|
37
|
+
stored = @store.fetch(provider: @provider.slug, account: @account)
|
|
38
|
+
return if stored.nil? || stored.expired?
|
|
39
|
+
|
|
40
|
+
file = confirmed_file(stored.id)
|
|
41
|
+
file unless file.nil? || file.expired?
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def confirmed_file(id)
|
|
45
|
+
FILES.fetch(key(id)) { @provider.find_file(id) }
|
|
46
|
+
rescue StandardError => e
|
|
47
|
+
RubyLLM.logger.debug { "Uploading again: #{@provider.slug} could not find #{id} (#{e.message})" }
|
|
48
|
+
nil
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def remember(upload)
|
|
52
|
+
FILES.fetch(key(upload.id)) { upload }
|
|
53
|
+
@store.store(upload, provider: @provider.slug, account: @account)
|
|
54
|
+
upload
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def key(id)
|
|
58
|
+
[@provider.slug, @account, id]
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -11,16 +11,11 @@ module RubyLLM
|
|
|
11
11
|
def initialize
|
|
12
12
|
@content = +''
|
|
13
13
|
@citations = []
|
|
14
|
+
@citation_keys = {}
|
|
14
15
|
@thinking_text = nil
|
|
15
16
|
@thinking_signature = nil
|
|
16
17
|
@tool_calls = {}
|
|
17
|
-
@
|
|
18
|
-
@output_tokens = nil
|
|
19
|
-
@cache_read_tokens = nil
|
|
20
|
-
@cache_write_tokens = nil
|
|
21
|
-
@thinking_tokens = nil
|
|
22
|
-
@server_tool_use = nil
|
|
23
|
-
@reported_cost = nil
|
|
18
|
+
@tokens = Tokens.new
|
|
24
19
|
@server_tool_calls = []
|
|
25
20
|
@raw_content = nil
|
|
26
21
|
@raw_reasoning = nil
|
|
@@ -53,15 +48,7 @@ module RubyLLM
|
|
|
53
48
|
text: @thinking_text,
|
|
54
49
|
signature: @thinking_signature
|
|
55
50
|
),
|
|
56
|
-
tokens:
|
|
57
|
-
input: @input_tokens,
|
|
58
|
-
output: @output_tokens,
|
|
59
|
-
cache_read: @cache_read_tokens,
|
|
60
|
-
cache_write: @cache_write_tokens,
|
|
61
|
-
thinking: @thinking_tokens,
|
|
62
|
-
server_tool_use: @server_tool_use,
|
|
63
|
-
reported_cost: @reported_cost
|
|
64
|
-
),
|
|
51
|
+
tokens: @tokens,
|
|
65
52
|
server_tool_calls: @server_tool_calls,
|
|
66
53
|
raw_content: @raw_content,
|
|
67
54
|
raw_reasoning: @raw_reasoning,
|
|
@@ -77,7 +64,11 @@ module RubyLLM
|
|
|
77
64
|
# Providers like Perplexity repeat the full citation list on every chunk.
|
|
78
65
|
def accumulate_citations(new_citations)
|
|
79
66
|
new_citations.each do |citation|
|
|
80
|
-
|
|
67
|
+
key = citation.to_h
|
|
68
|
+
next if @citation_keys.key?(key)
|
|
69
|
+
|
|
70
|
+
@citation_keys[key] = true
|
|
71
|
+
@citations << citation
|
|
81
72
|
end
|
|
82
73
|
end
|
|
83
74
|
|
|
@@ -182,14 +173,7 @@ module RubyLLM
|
|
|
182
173
|
end
|
|
183
174
|
|
|
184
175
|
def count_tokens(chunk)
|
|
185
|
-
tokens = chunk.tokens
|
|
186
|
-
@input_tokens = tokens.input if tokens.input
|
|
187
|
-
@output_tokens = tokens.output if tokens.output
|
|
188
|
-
@cache_read_tokens = tokens.cache_read if tokens.cache_read
|
|
189
|
-
@cache_write_tokens = tokens.cache_write if tokens.cache_write
|
|
190
|
-
@thinking_tokens = tokens.thinking if tokens.thinking
|
|
191
|
-
@server_tool_use = tokens.server_tool_use if tokens.server_tool_use
|
|
192
|
-
@reported_cost = tokens.reported_cost if tokens.reported_cost
|
|
176
|
+
@tokens = @tokens.merge(chunk.tokens)
|
|
193
177
|
end
|
|
194
178
|
|
|
195
179
|
def handle_chunk_content(chunk)
|
|
@@ -1,15 +1,23 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require 'event_stream_parser'
|
|
4
3
|
require 'faraday'
|
|
5
4
|
require 'json'
|
|
6
5
|
|
|
7
6
|
module RubyLLM
|
|
8
7
|
class Protocol
|
|
9
8
|
module Streaming # :nodoc: all
|
|
10
|
-
|
|
9
|
+
MAX_JSON_BODY_BYTES = 1024 * 1024
|
|
10
|
+
|
|
11
|
+
StreamState = Struct.new(:parser, :buffer, :json_body) do
|
|
11
12
|
def initialize
|
|
12
|
-
super
|
|
13
|
+
super
|
|
14
|
+
reset
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def reset
|
|
18
|
+
self.parser = Transport::EventStreamParser.new
|
|
19
|
+
self.buffer = +''
|
|
20
|
+
self.json_body = nil
|
|
13
21
|
end
|
|
14
22
|
end
|
|
15
23
|
|
|
@@ -33,13 +41,15 @@ module RubyLLM
|
|
|
33
41
|
# parsed event Hash. Returns the Faraday response.
|
|
34
42
|
def stream_events(url, payload, additional_headers = {}, &block)
|
|
35
43
|
progress = {}
|
|
36
|
-
|
|
44
|
+
fallback_state = StreamState.new
|
|
45
|
+
on_data = build_on_data_handler(progress, fallback_state: fallback_state) do |data|
|
|
37
46
|
block.call(data) if data.is_a?(Hash)
|
|
38
47
|
end
|
|
39
48
|
|
|
40
|
-
@connection.post url, payload, usage: @usage_tracker do |req|
|
|
49
|
+
@connection.post url, payload, usage: @usage_tracker, stream: true do |req|
|
|
41
50
|
req.headers = additional_headers.merge(req.headers) unless additional_headers.empty?
|
|
42
51
|
(req.options.context ||= {})[Transport::Connection::STREAM_PROGRESS_KEY] = progress
|
|
52
|
+
req.options.context[Transport::ErrorMiddleware::STREAM_RESET_KEY] = fallback_state.method(:reset)
|
|
43
53
|
if faraday_1?
|
|
44
54
|
req.options[:on_data] = on_data
|
|
45
55
|
else
|
|
@@ -60,12 +70,9 @@ module RubyLLM
|
|
|
60
70
|
Faraday::VERSION.start_with?('1')
|
|
61
71
|
end
|
|
62
72
|
|
|
63
|
-
#
|
|
64
|
-
#
|
|
65
|
-
|
|
66
|
-
def build_on_data_handler(progress = {}, &handler)
|
|
67
|
-
fallback_state = StreamState.new
|
|
68
|
-
|
|
73
|
+
# Some adapters omit the env, so ErrorMiddleware also resets the
|
|
74
|
+
# request's fallback state before each attempt.
|
|
75
|
+
def build_on_data_handler(progress = {}, fallback_state: StreamState.new, &handler)
|
|
69
76
|
FaradayHandlers.build(
|
|
70
77
|
faraday_v1: faraday_1?,
|
|
71
78
|
on_chunk: lambda { |chunk, env|
|
|
@@ -86,36 +93,35 @@ module RubyLLM
|
|
|
86
93
|
def process_stream_chunk(chunk, state, env, progress, &)
|
|
87
94
|
RubyLLM.logger.debug { "Received chunk: #{chunk}" } if RubyLLM.config.log_stream_debug
|
|
88
95
|
|
|
89
|
-
if
|
|
90
|
-
|
|
91
|
-
elsif json_error_payload?(chunk)
|
|
92
|
-
handle_json_error_chunk(chunk, env)
|
|
96
|
+
if json_body?(chunk, state)
|
|
97
|
+
handle_json_body(chunk, state, env)
|
|
93
98
|
else
|
|
94
99
|
handle_sse(chunk, state.parser, env, progress, &)
|
|
95
100
|
end
|
|
96
101
|
end
|
|
97
102
|
|
|
98
|
-
#
|
|
99
|
-
#
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
+
# Adapters cut reads anywhere, so a read inside an event can start with
|
|
104
|
+
# "{". Only the first bytes of a body can open a bare JSON error.
|
|
105
|
+
def json_body?(chunk, state)
|
|
106
|
+
state.json_body = chunk.lstrip.start_with?('{') if state.json_body.nil? && !chunk.strip.empty?
|
|
107
|
+
state.json_body
|
|
103
108
|
end
|
|
104
109
|
|
|
105
|
-
def
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
def handle_json_error_chunk(chunk, env)
|
|
110
|
-
parse_error_from_json(chunk, env, 'Failed to parse JSON error chunk')
|
|
111
|
-
end
|
|
110
|
+
def handle_json_body(chunk, state, env)
|
|
111
|
+
if state.buffer.bytesize + chunk.bytesize > MAX_JSON_BODY_BYTES
|
|
112
|
+
raise Error, "Streaming JSON response exceeds #{MAX_JSON_BODY_BYTES} bytes"
|
|
113
|
+
end
|
|
112
114
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
115
|
+
state.buffer << chunk
|
|
116
|
+
parsed = JSON.parse(state.buffer)
|
|
117
|
+
raise_stream_error(state.buffer, parsed, env) if parsed.is_a?(Hash) && parsed['error']
|
|
118
|
+
rescue JSON::ParserError
|
|
119
|
+
RubyLLM.logger.debug { 'Waiting for the remaining JSON response bytes' }
|
|
116
120
|
end
|
|
117
121
|
|
|
118
122
|
def handle_failed_response(chunk, buffer, env)
|
|
123
|
+
return if buffer.bytesize + chunk.bytesize > MAX_JSON_BODY_BYTES
|
|
124
|
+
|
|
119
125
|
buffer << chunk
|
|
120
126
|
error_data = JSON.parse(buffer)
|
|
121
127
|
raise_stream_error(buffer, error_data, env)
|
|
@@ -184,7 +190,8 @@ module RubyLLM
|
|
|
184
190
|
if faraday_1? || env.nil?
|
|
185
191
|
Struct.new(:body, :status).new(parsed_data, error_status)
|
|
186
192
|
else
|
|
187
|
-
|
|
193
|
+
# Faraday writes body into the request until a status is set.
|
|
194
|
+
env.merge(status: error_status, body: parsed_data)
|
|
188
195
|
end
|
|
189
196
|
end
|
|
190
197
|
|