llm_logs 0.4.1 → 0.6.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 +35 -16
- data/app/jobs/llm_logs/batch/flush_job.rb +1 -0
- data/app/jobs/llm_logs/batch/poll_job.rb +1 -0
- data/app/models/llm_logs/batch/reconciler.rb +1 -0
- data/app/models/llm_logs/batch/submitter.rb +9 -7
- data/app/models/llm_logs/batch/trace_recorder.rb +19 -7
- data/app/models/llm_logs/batch.rb +15 -28
- data/app/models/llm_logs/prompt.rb +11 -2
- data/app/models/llm_logs/span.rb +15 -3
- data/app/views/llm_logs/spans/show.html.erb +3 -3
- data/app/views/llm_logs/traces/index.html.erb +3 -3
- data/app/views/llm_logs/traces/show.html.erb +5 -3
- data/db/migrate/001_create_llm_logs_traces.rb +1 -1
- data/db/migrate/002_create_llm_logs_spans.rb +3 -3
- data/db/migrate/004_create_llm_logs_prompt_versions.rb +3 -3
- data/db/migrate/006_add_tags_to_prompts.rb +6 -2
- data/db/migrate/007_create_llm_logs_batches.rb +1 -1
- data/db/migrate/008_create_llm_logs_batch_requests.rb +2 -2
- data/db/migrate/009_add_provider_columns_to_llm_logs_batches.rb +1 -1
- data/lib/llm_logs/configuration.rb +1 -2
- data/lib/llm_logs/engine.rb +10 -1
- data/lib/llm_logs/instrumentation/ruby_llm_chat.rb +190 -82
- data/lib/llm_logs/ruby_llm_patches/bedrock_sigv4.rb +75 -0
- data/lib/llm_logs/ruby_llm_patches/converse_claude_adaptive_thinking.rb +70 -0
- data/lib/llm_logs/ruby_llm_patches/converse_foreign_reasoning.rb +80 -0
- data/lib/llm_logs/ruby_llm_patches/converse_reasoning_config.rb +78 -0
- data/lib/llm_logs/ruby_llm_patches/retry_ssl_error.rb +25 -0
- data/lib/llm_logs/ruby_llm_patches.rb +54 -0
- data/lib/llm_logs/version.rb +1 -1
- data/lib/llm_logs.rb +1 -5
- metadata +23 -13
- data/app/models/llm_logs/batch/adapters/openai_responses.rb +0 -63
- data/app/models/llm_logs/batch/schema_format.rb +0 -25
|
@@ -1,105 +1,213 @@
|
|
|
1
1
|
module LlmLogs
|
|
2
2
|
module Instrumentation
|
|
3
|
+
# Records RubyLLM 2.x chat activity as llm_logs spans by subscribing to the
|
|
4
|
+
# events RubyLLM emits for observability adapters (docs/_advanced/instrumentation.md):
|
|
5
|
+
#
|
|
6
|
+
# chat.ruby_llm -- one per model request (RubyLLM::Chat#generate_once): an "llm" span
|
|
7
|
+
# per provider round. Wraps every transport retry of that round, so a
|
|
8
|
+
# round that fails after its retries surfaces as :exception_object.
|
|
9
|
+
# With fallbacks, each model tried gets its own span.
|
|
10
|
+
# tool_call.ruby_llm -- one per local tool execution (RubyLLM::Chat#execute_tool): a "tool" span.
|
|
11
|
+
#
|
|
12
|
+
# Rounds and tools are siblings under the current span/trace:
|
|
13
|
+
# trace -> llm (round 1, asks for a tool) -> tool.x -> llm (round 2, final answer)
|
|
14
|
+
#
|
|
15
|
+
# The subscription is global (every RubyLLM::Chat, including acts_as_chat's to_llm and
|
|
16
|
+
# RubyLLM::Agent), but only fires while RubyLLM's instrumenter is ActiveSupport::Notifications,
|
|
17
|
+
# which RubyLLM's Railtie sets by default.
|
|
3
18
|
module RubyLlmChat
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
begin
|
|
18
|
-
result = super(&block)
|
|
19
|
-
span.record_response(result)
|
|
20
|
-
span.cost = llm_logs_compute_cost(result)
|
|
21
|
-
result
|
|
22
|
-
rescue => e
|
|
23
|
-
span.record_error(e)
|
|
24
|
-
llm_logs_capture_partial_tokens(span, messages_before)
|
|
25
|
-
raise
|
|
26
|
-
ensure
|
|
27
|
-
span.finish
|
|
19
|
+
SPAN_KEY = :llm_logs_span
|
|
20
|
+
USAGE_START_KEY = :llm_logs_usage_start
|
|
21
|
+
|
|
22
|
+
class << self
|
|
23
|
+
def install!
|
|
24
|
+
return if installed?
|
|
25
|
+
|
|
26
|
+
RubyLLM.config.instrumenter ||= ActiveSupport::Notifications
|
|
27
|
+
@subscribers = [
|
|
28
|
+
ActiveSupport::Notifications.subscribe("chat.ruby_llm", ChatSubscriber.new),
|
|
29
|
+
ActiveSupport::Notifications.subscribe("tool_call.ruby_llm", ToolSubscriber.new)
|
|
30
|
+
]
|
|
28
31
|
end
|
|
29
|
-
end
|
|
30
32
|
|
|
31
|
-
|
|
33
|
+
def uninstall!
|
|
34
|
+
Array(@subscribers).each { |subscriber| ActiveSupport::Notifications.unsubscribe(subscriber) }
|
|
35
|
+
@subscribers = nil
|
|
36
|
+
end
|
|
32
37
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
38
|
+
def installed?
|
|
39
|
+
!@subscribers.nil?
|
|
40
|
+
end
|
|
36
41
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
42
|
+
# Instrumentation must never break the LLM call it observes. ActiveSupport re-raises
|
|
43
|
+
# subscriber exceptions into the instrumented block, so every hook rescues.
|
|
44
|
+
def guard
|
|
45
|
+
yield
|
|
46
|
+
rescue StandardError => e
|
|
47
|
+
Rails.logger&.error("[llm_logs] instrumentation error: #{e.class}: #{e.message}")
|
|
48
|
+
nil
|
|
44
49
|
end
|
|
45
50
|
end
|
|
46
51
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
52
|
+
# Serializers shared by both subscribers.
|
|
53
|
+
module Serialize
|
|
54
|
+
module_function
|
|
50
55
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
56
|
+
def messages(list)
|
|
57
|
+
Array(list).map { |message| message_entry(message) }
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def message_entry(message)
|
|
61
|
+
entry = {role: message.role, content: message.content}
|
|
62
|
+
calls = tool_calls(message.tool_calls)
|
|
63
|
+
entry[:tool_calls] = calls if calls
|
|
64
|
+
entry[:tool_call_id] = message.tool_call_id if message.tool_call_id
|
|
65
|
+
entry
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def tool_calls(calls)
|
|
69
|
+
return nil if calls.nil? || calls.empty?
|
|
70
|
+
|
|
71
|
+
calls.values.map { |call| {id: call.id, name: call.name, arguments: call.arguments} }
|
|
72
|
+
end
|
|
58
73
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
74
|
+
# Structured (schema) answers are JSON text in 2.x; store the parsed Hash so the
|
|
75
|
+
# UI renders nested fields instead of an escaped string.
|
|
76
|
+
def response(message, schema:)
|
|
77
|
+
content = message.content
|
|
78
|
+
content = parse_json(content) if schema && content.is_a?(String) && !content.empty?
|
|
79
|
+
output = {content: content}
|
|
80
|
+
calls = tool_calls(message.tool_calls)
|
|
81
|
+
output[:tool_calls] = calls if calls
|
|
82
|
+
output[:thinking] = message.thinking.text if message.thinking&.text.present?
|
|
83
|
+
output
|
|
84
|
+
end
|
|
62
85
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
86
|
+
def parse_json(text)
|
|
87
|
+
JSON.parse(text)
|
|
88
|
+
rescue JSON::ParserError
|
|
89
|
+
text
|
|
90
|
+
end
|
|
66
91
|
|
|
67
|
-
(
|
|
92
|
+
def tool_result(result)
|
|
93
|
+
case result
|
|
94
|
+
when Hash then result
|
|
95
|
+
when Array then {result: result}
|
|
96
|
+
else {result: result.to_s}
|
|
97
|
+
end
|
|
98
|
+
end
|
|
68
99
|
end
|
|
69
100
|
|
|
70
|
-
|
|
71
|
-
|
|
101
|
+
class ChatSubscriber
|
|
102
|
+
def start(_name, _id, payload)
|
|
103
|
+
RubyLlmChat.guard do
|
|
104
|
+
next unless LlmLogs.enabled?
|
|
105
|
+
|
|
106
|
+
chat = payload[:chat]
|
|
107
|
+
payload[USAGE_START_KEY] = chat.usage_entries.length if chat.respond_to?(:usage_entries)
|
|
108
|
+
payload[SPAN_KEY] = LlmLogs::Tracer.start_span(
|
|
109
|
+
name: "chat.complete",
|
|
110
|
+
span_type: "llm",
|
|
111
|
+
model: payload[:model],
|
|
112
|
+
provider: payload[:provider],
|
|
113
|
+
input: Serialize.messages(payload[:input_messages]),
|
|
114
|
+
metadata: request_metadata(payload)
|
|
115
|
+
)
|
|
116
|
+
end
|
|
117
|
+
end
|
|
72
118
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
119
|
+
def finish(_name, _id, payload)
|
|
120
|
+
span = payload.delete(SPAN_KEY)
|
|
121
|
+
return unless span
|
|
122
|
+
|
|
123
|
+
RubyLlmChat.guard do
|
|
124
|
+
if (error = payload[:exception_object])
|
|
125
|
+
span.record_error(error)
|
|
126
|
+
else
|
|
127
|
+
span.output = Serialize.response(payload[:response], schema: payload[:schema])
|
|
128
|
+
span.set_attribute("finish_reason", payload[:response].finish_reason&.to_s)
|
|
129
|
+
response_model = payload[:response_model]
|
|
130
|
+
span.set_attribute("response_model", response_model) if response_model && response_model != payload[:model]
|
|
131
|
+
end
|
|
132
|
+
record_usage(span, payload)
|
|
133
|
+
end
|
|
134
|
+
ensure
|
|
135
|
+
RubyLlmChat.guard { span&.finish }
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
private
|
|
139
|
+
|
|
140
|
+
def request_metadata(payload)
|
|
141
|
+
{
|
|
142
|
+
"streaming" => payload[:streaming],
|
|
143
|
+
"tools" => Array(payload[:tools]).map(&:to_s),
|
|
144
|
+
"temperature" => payload[:temperature],
|
|
145
|
+
"thinking" => thinking_metadata(payload[:thinking]),
|
|
146
|
+
"schema" => payload[:schema] && payload[:schema][:name]
|
|
147
|
+
}.compact
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def thinking_metadata(thinking)
|
|
151
|
+
return nil unless thinking
|
|
152
|
+
|
|
153
|
+
{"effort" => thinking.effort&.to_s, "budget" => thinking.budget, "enabled" => thinking.enabled}.compact.presence
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# Tokens and cost of every transport attempt this round made (retries included), read
|
|
157
|
+
# from the chat's usage ledger. Falls back to the event's tokens/cost when the ledger is
|
|
158
|
+
# unavailable. A failed attempt whose usage is unknown (e.g. a timeout that may have
|
|
159
|
+
# been billed) is left out of the cost and flagged with cost_complete: false.
|
|
160
|
+
def record_usage(span, payload)
|
|
161
|
+
entries = round_usage_entries(payload)
|
|
162
|
+
if entries
|
|
163
|
+
tokens = RubyLLM::Tokens.aggregate(entries.map(&:tokens))
|
|
164
|
+
cost = RubyLLM::Cost.aggregate(entries.map(&:cost))
|
|
165
|
+
span.set_attribute("attempts", entries.size) if entries.size > 1
|
|
166
|
+
span.set_attribute("cost_complete", false) unless entries.all?(&:cost_available?)
|
|
167
|
+
else
|
|
168
|
+
tokens = payload[:tokens]
|
|
169
|
+
cost = payload[:cost]
|
|
170
|
+
end
|
|
171
|
+
span.record_tokens(tokens)
|
|
172
|
+
span.cost = cost&.total
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def round_usage_entries(payload)
|
|
176
|
+
start = payload[USAGE_START_KEY]
|
|
177
|
+
chat = payload[:chat]
|
|
178
|
+
return nil unless start && chat.respond_to?(:usage_entries)
|
|
179
|
+
|
|
180
|
+
chat.usage_entries.drop(start)
|
|
181
|
+
end
|
|
76
182
|
end
|
|
77
183
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
span
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
184
|
+
class ToolSubscriber
|
|
185
|
+
def start(_name, _id, payload)
|
|
186
|
+
RubyLlmChat.guard do
|
|
187
|
+
next unless LlmLogs.enabled?
|
|
188
|
+
|
|
189
|
+
payload[SPAN_KEY] = LlmLogs::Tracer.start_span(
|
|
190
|
+
name: "tool.#{payload[:tool_name]}",
|
|
191
|
+
span_type: "tool",
|
|
192
|
+
input: payload[:tool_arguments],
|
|
193
|
+
metadata: {tool_name: payload[:tool_name].to_s, tool_call_id: payload[:tool_call_id]}.compact
|
|
194
|
+
)
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def finish(_name, _id, payload)
|
|
199
|
+
span = payload.delete(SPAN_KEY)
|
|
200
|
+
return unless span
|
|
201
|
+
|
|
202
|
+
RubyLlmChat.guard do
|
|
203
|
+
if (error = payload[:exception_object])
|
|
204
|
+
span.record_error(error)
|
|
205
|
+
else
|
|
206
|
+
span.output = Serialize.tool_result(payload[:result])
|
|
207
|
+
end
|
|
208
|
+
end
|
|
101
209
|
ensure
|
|
102
|
-
span
|
|
210
|
+
RubyLlmChat.guard { span&.finish }
|
|
103
211
|
end
|
|
104
212
|
end
|
|
105
213
|
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
require "faraday"
|
|
2
|
+
|
|
3
|
+
module LlmLogs
|
|
4
|
+
module RubyLLMPatches
|
|
5
|
+
# Signs every Bedrock attempt when it is sent.
|
|
6
|
+
# Backport of crmne/ruby_llm#1024 ("Sign each Bedrock attempt when it is sent").
|
|
7
|
+
#
|
|
8
|
+
# ruby_llm 2.0.0 signs inside the request block (Protocols::Converse#signed_post,
|
|
9
|
+
# Converse::Streaming#stream_response, InvokeModel, Mantle, Guardrails, Rerank, ...),
|
|
10
|
+
# i.e. once, before Faraday's retry middleware runs. A retry after a long timeout
|
|
11
|
+
# (request_timeout 300s vs SigV4's 5-minute window) then replays an expired
|
|
12
|
+
# X-Amz-Date/Authorization and AWS answers 403 "Signature expired", which is not retried.
|
|
13
|
+
#
|
|
14
|
+
# This adds a middleware innermost in every Bedrock Transport::Connection stack (after
|
|
15
|
+
# :retry and after the :json encoder), so each attempt is re-signed over the exact body
|
|
16
|
+
# bytes being sent, at send time. It only touches requests that already carry a SigV4
|
|
17
|
+
# Authorization header and keeps the signing service named in it (bedrock,
|
|
18
|
+
# bedrock-mantle, ...). Auth#signed_get/#signed_post (model listing, batch control
|
|
19
|
+
# endpoints) use Connection.basic, which has no retry, so they need no change.
|
|
20
|
+
module BedrockSigV4
|
|
21
|
+
class Middleware < Faraday::Middleware
|
|
22
|
+
CREDENTIAL = %r{\AAWS4-HMAC-SHA256 Credential=[^/]+/\d{8}/[^/]+/(?<service>[^/]+)/aws4_request}
|
|
23
|
+
|
|
24
|
+
def initialize(app, provider:)
|
|
25
|
+
super(app)
|
|
26
|
+
@provider = provider
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def call(env)
|
|
30
|
+
resign(env)
|
|
31
|
+
@app.call(env)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def resign(env)
|
|
37
|
+
match = CREDENTIAL.match(env.request_headers["Authorization"].to_s)
|
|
38
|
+
return unless match
|
|
39
|
+
|
|
40
|
+
body = env.body.nil? ? "" : env.body
|
|
41
|
+
return unless body.is_a?(String) # multipart/IO bodies keep the original signature
|
|
42
|
+
|
|
43
|
+
url = env.url
|
|
44
|
+
path = url.query ? "#{url.path}?#{url.query}" : url.path
|
|
45
|
+
headers = @provider.sign_headers(
|
|
46
|
+
env.method.to_s.upcase, path, body,
|
|
47
|
+
base_url: "#{url.scheme}://#{url.host}", service: match[:service]
|
|
48
|
+
)
|
|
49
|
+
env.request_headers.delete("X-Amz-Security-Token") # credentials may have rotated
|
|
50
|
+
env.request_headers.merge!(headers.except("Content-Type"))
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
module ConnectionPatch
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
def setup_middleware(faraday)
|
|
58
|
+
super
|
|
59
|
+
faraday.use(Middleware, provider: @provider) if BedrockSigV4.bedrock?(@provider)
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
module_function
|
|
64
|
+
|
|
65
|
+
def bedrock?(provider)
|
|
66
|
+
defined?(RubyLLM::Providers::Bedrock) && provider.is_a?(RubyLLM::Providers::Bedrock) &&
|
|
67
|
+
provider.respond_to?(:sign_headers)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def install!
|
|
71
|
+
RubyLLMPatches.prepend_checked(RubyLLM::Transport::Connection, ConnectionPatch, %i[setup_middleware])
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
module LlmLogs
|
|
2
|
+
module RubyLLMPatches
|
|
3
|
+
# Adaptive thinking for adaptive-only Claude models on Bedrock Converse.
|
|
4
|
+
# Backport of crmne/ruby_llm#1025 ("Think adaptively on adaptive-only Claude via Converse").
|
|
5
|
+
#
|
|
6
|
+
# ruby_llm 2.0.0 (Protocols::Converse::Chat#format_reasoning_fields) turns a Claude
|
|
7
|
+
# reasoning effort into a fixed budget, `additionalModelRequestFields: {reasoning_config:
|
|
8
|
+
# {type: "enabled", budget_tokens: N}}`, sized from the Converse budgetTokens schema.
|
|
9
|
+
# Claude models that only think adaptively (the registry advertises an effort option and
|
|
10
|
+
# no budget_tokens option: Sonnet 5, Opus 4.7/4.8/5, Fable 5) reject it with
|
|
11
|
+
# `"thinking.type.enabled" is not supported for this model. Use "thinking.type.adaptive"
|
|
12
|
+
# and "output_config.effort"`, and with_thinking(true) sends no thinking at all. They take
|
|
13
|
+
# `{thinking: {type: "adaptive"}, output_config: {effort: "low"}}`, the rule upstream's own
|
|
14
|
+
# Anthropic protocol already applies (Protocols::Anthropic::Chat#thinking_mode).
|
|
15
|
+
#
|
|
16
|
+
# For an adaptive-only Claude target with no budget set:
|
|
17
|
+
# - effort other than "none": adaptive thinking, effort in output_config (every advertised
|
|
18
|
+
# tier, xhigh and max included);
|
|
19
|
+
# - no effort (with_thinking(true), or a display alone): adaptive thinking alone;
|
|
20
|
+
# - effort "none": nothing.
|
|
21
|
+
# No `display` is sent (Converse has no field for it). Everything else goes to super: any
|
|
22
|
+
# budget, thinking turned off (reasoning_config disabled), budget-style Claude (Haiku 4.5,
|
|
23
|
+
# Sonnet 4.6), GPT, Nova and other vendors.
|
|
24
|
+
#
|
|
25
|
+
# The target is Anthropic under any region prefix (ConverseForeignReasoning::ANTHROPIC).
|
|
26
|
+
# A model with no reasoning options of its own (an unregistered id, such as an "in."
|
|
27
|
+
# inference profile) takes them from a Bedrock registry entry for the same foundation model.
|
|
28
|
+
module ConverseClaudeAdaptiveThinking
|
|
29
|
+
ANTHROPIC = ConverseForeignReasoning::ANTHROPIC
|
|
30
|
+
REGION = /\A[a-z0-9-]+\.(?=anthropic\.)/
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def format_reasoning_fields(thinking, model, max_output_tokens = nil)
|
|
35
|
+
return super unless thinking&.enabled? && thinking.enabled != false && thinking.budget.nil?
|
|
36
|
+
return super unless llm_logs_adaptive_only_claude?(model)
|
|
37
|
+
|
|
38
|
+
effort = thinking.effort.to_s
|
|
39
|
+
return nil if effort == "none"
|
|
40
|
+
return {thinking: {type: "adaptive"}} if effort.empty?
|
|
41
|
+
|
|
42
|
+
{thinking: {type: "adaptive"}, output_config: {effort: effort}}
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def llm_logs_adaptive_only_claude?(model)
|
|
46
|
+
id = foundation_model_id(model&.id)
|
|
47
|
+
return false unless ANTHROPIC.match?(id)
|
|
48
|
+
|
|
49
|
+
options = llm_logs_reasoning_options(model, id.sub(REGION, ""))
|
|
50
|
+
options.any? { |o| o[:type] == "effort" } && options.none? { |o| o[:type] == "budget_tokens" }
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def llm_logs_reasoning_options(model, bare_id)
|
|
54
|
+
return model.reasoning_options if model.reasoning_options.any?
|
|
55
|
+
|
|
56
|
+
sibling = RubyLLM.models.all.find do |candidate|
|
|
57
|
+
candidate.provider == "bedrock" && candidate.reasoning_options.any? &&
|
|
58
|
+
foundation_model_id(candidate.id).sub(REGION, "") == bare_id
|
|
59
|
+
end
|
|
60
|
+
sibling ? sibling.reasoning_options : []
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def self.install!
|
|
64
|
+
RubyLLMPatches.prepend_checked(
|
|
65
|
+
RubyLLM::Protocols::Converse, self, %i[format_reasoning_fields foundation_model_id]
|
|
66
|
+
)
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
module LlmLogs
|
|
2
|
+
module RubyLLMPatches
|
|
3
|
+
# Bedrock Converse replays a message's reasoning only to a model of the same family.
|
|
4
|
+
# Backport of crmne/ruby_llm#1026, stricter than it on purpose (see below).
|
|
5
|
+
#
|
|
6
|
+
# ruby_llm 2.0.0 (Protocols::Converse::Chat#format_thinking_blocks) replays every assistant
|
|
7
|
+
# message's reasoning (raw_reasoning["converse"] blocks, else thinking text/signature) as
|
|
8
|
+
# reasoningContent whatever the target model. Upstream only strips thinking a *different
|
|
9
|
+
# provider* produced (Protocol#foreign_thinking?), and Claude and GPT are both "bedrock".
|
|
10
|
+
# So a chat Claude answered and GPT continues fails with 400 "This model doesn't support
|
|
11
|
+
# the reasoningContent.reasoningText.text field for assistant message", and a chat GPT
|
|
12
|
+
# answered and Claude continues hands Claude GPT's encrypted reasoning (redactedContent,
|
|
13
|
+
# or a reasoningText with an empty text and GPT's blob as signature for rows migrated from
|
|
14
|
+
# 1.16), which Claude cannot validate.
|
|
15
|
+
#
|
|
16
|
+
# The family of a model id is "anthropic", "openai", or the vendor segment of any other
|
|
17
|
+
# Bedrock id ("amazon", "meta", ...), whatever region prefix it carries. The producer is
|
|
18
|
+
# RubyLLM::Message#model (the reply's model, or for a restored row the model of its last
|
|
19
|
+
# successful ruby_llm_usages entry).
|
|
20
|
+
#
|
|
21
|
+
# - Producer known and of another family: no reasoning is replayed.
|
|
22
|
+
# - Anthropic target (same family or producer unknown): super, unchanged.
|
|
23
|
+
# - Other named-vendor target (same family or producer unknown): only the redactedContent
|
|
24
|
+
# blocks in raw_reasoning["converse"] (how GPT keeps its own encrypted reasoning across
|
|
25
|
+
# tool-call rounds); reasoningText and the thinking text/signature fallback are dropped.
|
|
26
|
+
# - Target whose id names no model (application-inference-profile ARN): super, unchanged.
|
|
27
|
+
#
|
|
28
|
+
# An application inference profile id is opaque, and AWS allows dots in it
|
|
29
|
+
# (".../application-inference-profile/team.prod"), so it names no family as producer or
|
|
30
|
+
# target (backport of crmne/ruby_llm#1026, "Give application inference profiles no vendor").
|
|
31
|
+
# The rest of this module is stricter than #1026 on purpose: for a non-Anthropic target
|
|
32
|
+
# whose producer is unknown or of the same family, upstream replays all the reasoning, while
|
|
33
|
+
# this keeps only redactedContent.
|
|
34
|
+
module ConverseForeignReasoning
|
|
35
|
+
ANTHROPIC = /(?:\A|\.)anthropic\./
|
|
36
|
+
OPENAI = /(?:\A|\.)openai\./
|
|
37
|
+
VENDOR = /\A[a-z0-9-]+(?=\.)/
|
|
38
|
+
APPLICATION_INFERENCE_PROFILE = ":application-inference-profile/"
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def format_thinking_blocks(msg)
|
|
43
|
+
target = llm_logs_model_family(model&.id)
|
|
44
|
+
return super if target.nil?
|
|
45
|
+
|
|
46
|
+
producer = llm_logs_model_family(msg.model) if msg.respond_to?(:model)
|
|
47
|
+
return [] if producer && producer != target
|
|
48
|
+
return super if target == "anthropic"
|
|
49
|
+
|
|
50
|
+
blocks = msg.raw_reasoning["converse"] || msg.raw_reasoning[:converse] if msg.raw_reasoning.is_a?(Hash)
|
|
51
|
+
kept = Array(blocks).select { |block| llm_logs_redacted_only?(block) }
|
|
52
|
+
RubyLLM::Support::Utils.deep_dup(kept)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# "anthropic", "openai", another vendor segment, or nil when the id names no model.
|
|
56
|
+
def llm_logs_model_family(model_id)
|
|
57
|
+
return if model_id.to_s.include?(APPLICATION_INFERENCE_PROFILE)
|
|
58
|
+
|
|
59
|
+
id = foundation_model_id(model_id)
|
|
60
|
+
return "anthropic" if ANTHROPIC.match?(id)
|
|
61
|
+
return "openai" if OPENAI.match?(id)
|
|
62
|
+
|
|
63
|
+
id[VENDOR]
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def llm_logs_redacted_only?(block)
|
|
67
|
+
content = block["reasoningContent"] || block[:reasoningContent] if block.is_a?(Hash)
|
|
68
|
+
return false unless content.is_a?(Hash)
|
|
69
|
+
|
|
70
|
+
content.keys.map(&:to_s) == ["redactedContent"]
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def self.install!
|
|
74
|
+
RubyLLMPatches.prepend_checked(
|
|
75
|
+
RubyLLM::Protocols::Converse, self, %i[format_thinking_blocks foundation_model_id model]
|
|
76
|
+
)
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
|
|
3
|
+
module LlmLogs
|
|
4
|
+
module RubyLLMPatches
|
|
5
|
+
# Reasoning effort as the reasoning_config value on Bedrock Converse.
|
|
6
|
+
# Backport of crmne/ruby_llm#1025 ("Send reasoning_config to models that publish it").
|
|
7
|
+
#
|
|
8
|
+
# ruby_llm 2.0.0 (Protocols::Converse::Chat#format_reasoning_fields) sends the effort of a
|
|
9
|
+
# model without a reasoning-budget schema as `additionalModelRequestFields:
|
|
10
|
+
# {reasoning_effort: "low"}`, which Bedrock rejects for OpenAI GPT models (400
|
|
11
|
+
# unknown_parameter). Their Converse additionalRequestFieldsSchema publishes
|
|
12
|
+
# `{"reasoning_config": {"type": "enum", "enum": ["none", "low", ...]}}`, and they take the
|
|
13
|
+
# effort as that value: `{reasoning_config: "low"}` ("none" turns reasoning off).
|
|
14
|
+
#
|
|
15
|
+
# Upstream rule, applied as #1025 does: when the model's schema, or the schema of another
|
|
16
|
+
# Bedrock registry entry for the same foundation model, publishes that enum, an effort that
|
|
17
|
+
# would have become reasoning_effort becomes the reasoning_config value, "none" included.
|
|
18
|
+
# Thinking off (reasoning_config disabled), budgets (explicit, or from a budget schema),
|
|
19
|
+
# Nova (reasoningConfig) and an empty effort are unchanged.
|
|
20
|
+
#
|
|
21
|
+
# Fallback (not in #1025): OpenAI GPT ids with no published schema get the same shape. ruby_llm
|
|
22
|
+
# 2.0.0's packaged registry lacks GPT-6 Sol/Luna, and an app catalog that registers them
|
|
23
|
+
# without Converse metadata leaves them schema-less. The match is prefix-agnostic (upstream
|
|
24
|
+
# REGION_PREFIXES lacks "in." in 2.0.0) and skips gpt-oss, which publishes no such enum.
|
|
25
|
+
module ConverseReasoningConfig
|
|
26
|
+
OPENAI_GPT = /(?:\A|\.)openai\.gpt-(?!oss)/
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def format_reasoning_fields(thinking, model, max_output_tokens = nil)
|
|
31
|
+
return super unless thinking&.enabled? && thinking.enabled != false
|
|
32
|
+
return super unless llm_logs_reasoning_config?(model)
|
|
33
|
+
|
|
34
|
+
effort = thinking.effort.to_s
|
|
35
|
+
return super if effort.empty? || nova_model?(model)
|
|
36
|
+
return super if reasoning_budget(thinking, effort, model, max_output_tokens)
|
|
37
|
+
|
|
38
|
+
{reasoning_config: effort}
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def llm_logs_reasoning_config?(model)
|
|
42
|
+
return false unless model
|
|
43
|
+
|
|
44
|
+
llm_logs_reasoning_config_schema?(model) || OPENAI_GPT.match?(foundation_model_id(model.id))
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Bedrock publishes Converse metadata for only some regional entries of a model.
|
|
48
|
+
def llm_logs_reasoning_config_schema?(model)
|
|
49
|
+
return true if llm_logs_publishes_reasoning_config?(model)
|
|
50
|
+
|
|
51
|
+
foundation_id = foundation_model_id(model.id)
|
|
52
|
+
RubyLLM.models.all.any? do |candidate|
|
|
53
|
+
candidate.provider == "bedrock" && candidate.id != model.id &&
|
|
54
|
+
foundation_model_id(candidate.id) == foundation_id && llm_logs_publishes_reasoning_config?(candidate)
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def llm_logs_publishes_reasoning_config?(model)
|
|
59
|
+
metadata = RubyLLM::Support::Utils.deep_symbolize_keys(model.metadata || {})
|
|
60
|
+
raw_schema = metadata.dig(:converse, :additionalRequestFieldsSchema)
|
|
61
|
+
return false unless raw_schema.is_a?(String)
|
|
62
|
+
|
|
63
|
+
schema = JSON.parse(raw_schema, symbolize_names: true)
|
|
64
|
+
config = schema.is_a?(Hash) ? schema[:reasoning_config] : nil
|
|
65
|
+
config.is_a?(Hash) && config[:type] == "enum"
|
|
66
|
+
rescue JSON::ParserError
|
|
67
|
+
false
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def self.install!
|
|
71
|
+
RubyLLMPatches.prepend_checked(
|
|
72
|
+
RubyLLM::Protocols::Converse, self,
|
|
73
|
+
%i[format_reasoning_fields foundation_model_id nova_model? reasoning_budget]
|
|
74
|
+
)
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
require "faraday"
|
|
2
|
+
|
|
3
|
+
module LlmLogs
|
|
4
|
+
module RubyLLMPatches
|
|
5
|
+
# Backport of crmne/ruby_llm#1024 ("Retry requests that fail with a TLS error").
|
|
6
|
+
# Retries TLS handshake failures (Faraday::SSLError, e.g. "SSL_connect ... unexpected
|
|
7
|
+
# eof"). faraday-net_http wraps every OpenSSL::SSL::SSLError as Faraday::SSLError, including
|
|
8
|
+
# read errors after the request was written, so this is the same trade-off ruby_llm
|
|
9
|
+
# already accepts for read timeouts. ruby_llm's retry_if (transport/connection.rb) still
|
|
10
|
+
# refuses non-idempotent requests and streams that already delivered content.
|
|
11
|
+
# Faraday::SSLError is not a Faraday::ConnectionFailed, so 2.0.0's retry list misses it.
|
|
12
|
+
module RetrySSLError
|
|
13
|
+
private
|
|
14
|
+
|
|
15
|
+
def retry_exceptions
|
|
16
|
+
exceptions = super
|
|
17
|
+
exceptions.include?(Faraday::SSLError) ? exceptions : exceptions + [Faraday::SSLError]
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def self.install!
|
|
21
|
+
RubyLLMPatches.prepend_checked(RubyLLM::Transport::Connection, self, %i[retry_exceptions])
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|