strongmind-platform-sdk 3.29.0 → 3.30.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 799fe97b77fb105775c8b846e6916380be6845209a5157633d832186ae81bd25
4
- data.tar.gz: ee0bbab328e6e27bcf72a5e634535f9ed0ab91bb57976b94a9c1fd24fac268f1
3
+ metadata.gz: 47af8235ee05e3c5a4e31ac2f6ad5a4936dcfd004ce0a0eae2737cf19ca59440
4
+ data.tar.gz: e6814730ac3a3187e89554d7b7dd3d99b629f3833f41a72de4f332f59a769a72
5
5
  SHA512:
6
- metadata.gz: 7080031758db9d9bd0894dcc2b8054df0377f1bc33a6da0ba987f7be9888c343cb0e2feb83d9d811c95114e1891ff4a62c1cde7d445333b35a5bcbf6a2b4007a
7
- data.tar.gz: c8e8bb3e06b1b6808cbc02c50664921dd6850520050cf5e0191a4bc01b3e2e4357bb8d0fa4c4edd14a95db1134d351a5ca3fbd1105243eb0cfd317ac85955f80
6
+ metadata.gz: e47283eb744412ddd608074dd12c38433db14273eb55ba3df3c5f60737b02e687b2ecce1ad70463d01709be5c6914178512c9d416f15c403444e6bf241d0e4e6
7
+ data.tar.gz: 24aeba0a4181cdf7cfda05ad764c54d0348016232029e0db2fe7b4c1a0362c43c75618c14eeb3bce79b47cda9977f8f296808d499681bf327f8e2ebb2c3909a8
data/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [3.30.0] - 2026-05-14
4
+
5
+ - Add `PlatformSdk::Observability::Langfuse::OpenAIAdapter` — wraps direct `ruby-openai` chat calls and fires `llm_call.platform_sdk` notifications with model, input messages, output content, and token usage extracted from OpenAI's response shape. Apps that hit OpenAI's API outside of RubyLLM now get the same Langfuse generation observations.
6
+ - Add `PlatformSdk::Observability::Langfuse::BedrockClaudeAdapter` — wraps direct AWS Bedrock `invoke_model` calls for Anthropic Claude (Messages API on Bedrock). Supports both non-streaming (`with_observability`) and event-streaming (`with_streaming_observability { |collector| ... }`) modes; the streaming collector accumulates `content_block_delta` text and pulls final token counts from `message_stop`'s `amazon-bedrock-invocationMetrics`.
7
+ - Both adapters expose `with_observability` block helpers (success/failure fires + re-raises) and a bare `fire(...)` method for after-the-fact emission. Neither adapter requires its underlying gem (`ruby-openai`, `aws-sdk-bedrockruntime`) at load time — the SDK loads cleanly without them.
8
+
3
9
  ## [3.29.0] - 2026-05-12
4
10
 
5
11
  - Add `PlatformSdk::Observability::Langfuse::NotificationSubscriber` — subscribes to the `llm_call.platform_sdk` `ActiveSupport::Notifications` event and forwards to `Recorder.record_generation`. Apps can instrument LLM calls without coupling to a host concern; adding a second observability backend later is one more `subscribe` call.
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- strongmind-platform-sdk (3.29.0)
4
+ strongmind-platform-sdk (3.30.0)
5
5
  asset_sync
6
6
  aws-sdk-cloudwatch
7
7
  aws-sdk-secretsmanager (~> 1.66)
@@ -154,7 +154,7 @@ GEM
154
154
  ffi (1.17.0)
155
155
  ffi (1.17.0-x86_64-darwin)
156
156
  ffi (1.17.0-x86_64-linux-gnu)
157
- fog-aws (3.33.2)
157
+ fog-aws (3.33.1)
158
158
  base64 (>= 0.2, < 0.4)
159
159
  fog-core (~> 2.6)
160
160
  fog-json (~> 1.1)
@@ -164,7 +164,7 @@ GEM
164
164
  excon (~> 1.0)
165
165
  formatador (>= 0.2, < 2.0)
166
166
  mime-types
167
- fog-json (1.3.0)
167
+ fog-json (1.2.0)
168
168
  fog-core
169
169
  multi_json (~> 1.10)
170
170
  fog-xml (0.1.5)
@@ -177,12 +177,6 @@ GEM
177
177
  google-protobuf (4.33.6)
178
178
  bigdecimal
179
179
  rake (>= 13)
180
- google-protobuf (4.33.6-x86_64-darwin)
181
- bigdecimal
182
- rake (>= 13)
183
- google-protobuf (4.33.6-x86_64-linux-gnu)
184
- bigdecimal
185
- rake (>= 13)
186
180
  googleapis-common-protos-types (1.22.0)
187
181
  google-protobuf (~> 4.26)
188
182
  hashdiff (1.1.0)
@@ -213,7 +207,7 @@ GEM
213
207
  mime-types (3.7.0)
214
208
  logger
215
209
  mime-types-data (~> 3.2025, >= 3.2025.0507)
216
- mime-types-data (3.2026.0414)
210
+ mime-types-data (3.2026.0317)
217
211
  mini_mime (1.1.5)
218
212
  mini_portile2 (2.8.7)
219
213
  minitest (5.24.1)
@@ -221,7 +215,7 @@ GEM
221
215
  mutex_m (0.2.0)
222
216
  net-http (0.4.1)
223
217
  uri
224
- net-imap (0.6.4)
218
+ net-imap (0.6.3)
225
219
  date
226
220
  net-protocol
227
221
  net-pop (0.1.2)
@@ -0,0 +1,244 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_support/notifications'
4
+ require 'json'
5
+
6
+ module PlatformSdk
7
+ module Observability
8
+ module Langfuse
9
+ # Fires `llm_call.platform_sdk` notifications for Anthropic Claude calls
10
+ # made directly through the AWS Bedrock SDK (rather than via RubyLLM).
11
+ # Two entry points cover the two Bedrock invocation modes:
12
+ #
13
+ # # Non-streaming (`bedrock_client.invoke_model(payload)`)
14
+ # PlatformSdk::Observability::Langfuse::BedrockClaudeAdapter.with_observability(
15
+ # payload: { model_id: 'us.anthropic.claude-sonnet-4-6',
16
+ # body: JSON.dump(anthropic_messages_payload) },
17
+ # context: 'generate_outline'
18
+ # ) { bedrock_client.invoke_model(payload) }
19
+ #
20
+ # # Streaming (`bedrock_client.invoke_model_with_response_stream`)
21
+ # PlatformSdk::Observability::Langfuse::BedrockClaudeAdapter.with_streaming_observability(
22
+ # payload: { model_id: ..., body: JSON.dump(...) },
23
+ # context: 'chat_stream'
24
+ # ) do |collector|
25
+ # bedrock_client.invoke_model_with_response_stream(
26
+ # payload.merge(event_stream_handler: proc do |stream|
27
+ # stream.on_chunk_event do |event|
28
+ # collector.observe(event)
29
+ # # ...the caller's own chunk handling
30
+ # end
31
+ # end)
32
+ # )
33
+ # end
34
+ #
35
+ # The streaming collector accumulates text from `content_block_delta`
36
+ # events and pulls the final `input_tokens`/`output_tokens` from the
37
+ # `message_stop` event's `amazon-bedrock-invocationMetrics`. The
38
+ # notification fires once after the block returns.
39
+ #
40
+ # No hard dependency on the AWS SDK — this adapter only reads the
41
+ # documented JSON shape of Anthropic's Messages API on Bedrock.
42
+ module BedrockClaudeAdapter
43
+ class << self
44
+ def with_observability(payload:, context:)
45
+ response = yield
46
+ fire(payload:, response:, context:)
47
+ response
48
+ rescue StandardError => e
49
+ fire(payload:, response: nil, context:, error: e)
50
+ raise
51
+ end
52
+
53
+ def with_streaming_observability(payload:, context:)
54
+ collector = StreamCollector.new
55
+ result = yield collector
56
+ fire_from_collector(payload:, collector:, context:)
57
+ result
58
+ rescue StandardError => e
59
+ fire_from_collector(payload:, collector:, context:, error: e)
60
+ raise
61
+ end
62
+
63
+ # After-the-fact emission for non-streaming calls. Caller can use
64
+ # this if they want to invoke Bedrock outside of the block helper
65
+ # and emit observability separately.
66
+ def fire(payload:, response:, context:, error: nil)
67
+ attrs = decode_response(response)
68
+ notify(payload:, output: attrs[:output], usage: attrs[:usage], context:, error:)
69
+ rescue StandardError => e
70
+ OpenTelemetry.handle_error(
71
+ message: "BedrockClaudeAdapter.fire failed: #{e.class}: #{e.message[0, 200]}"
72
+ )
73
+ nil
74
+ end
75
+
76
+ private
77
+
78
+ def fire_from_collector(payload:, collector:, context:, error: nil)
79
+ usage = collector.usage
80
+ notify(
81
+ payload:,
82
+ output: collector.text.empty? ? nil : collector.text,
83
+ usage: usage.values.any? ? usage : {},
84
+ context:,
85
+ error:
86
+ )
87
+ end
88
+
89
+ def notify(payload:, output:, usage:, context:, error:)
90
+ built = build_payload(payload:, output:, usage:, context:, error:)
91
+ return unless built
92
+
93
+ ActiveSupport::Notifications.instrument(LLM_CALL_EVENT, built)
94
+ rescue StandardError => e
95
+ OpenTelemetry.handle_error(
96
+ message: "BedrockClaudeAdapter.fire failed: #{e.class}: #{e.message[0, 200]}"
97
+ )
98
+ nil
99
+ end
100
+
101
+ def build_payload(payload:, output:, usage:, context:, error:)
102
+ return nil unless Langfuse.enabled?
103
+
104
+ # Tolerate non-Hash payloads (some callers pass `to_json`'d strings
105
+ # in tests) — extract only when the shape is what we expect.
106
+ payload = payload.is_a?(Hash) ? payload : {}
107
+ body = decode_body(payload[:body] || payload['body'])
108
+
109
+ {
110
+ name: context || 'llm_call',
111
+ model: payload[:model_id] || payload['model_id'] || body[:model] || body['model'],
112
+ input: body[:messages] || body['messages'],
113
+ output:,
114
+ usage: usage || {},
115
+ provider: 'aws.bedrock',
116
+ error:
117
+ }
118
+ end
119
+
120
+ def decode_body(body)
121
+ return body if body.is_a?(Hash)
122
+ return {} if body.nil?
123
+
124
+ JSON.parse(body.to_s)
125
+ rescue JSON::ParserError
126
+ {}
127
+ end
128
+
129
+ # Pulls `output` (concatenated text from content blocks) and `usage`
130
+ # (`input_tokens`/`output_tokens`) from a non-streaming Anthropic
131
+ # Messages-on-Bedrock response. Tolerant of both `response.body`
132
+ # (AWS SDK Seahorse) and plain Hash shapes for ease of testing.
133
+ def decode_response(response)
134
+ return { output: nil, usage: {} } if response.nil?
135
+
136
+ body = response.respond_to?(:body) ? response.body : response
137
+ # Rewind after read so the caller can re-read response.body
138
+ # downstream — Bedrock's Seahorse response wraps a StringIO that
139
+ # would otherwise be consumed by our instrumentation.
140
+ if body.respond_to?(:read)
141
+ raw = body.read
142
+ body.rewind if body.respond_to?(:rewind)
143
+ body = raw
144
+ end
145
+ parsed = body.is_a?(Hash) ? body : safe_parse_json(body.to_s)
146
+
147
+ { output: extract_text(parsed), usage: extract_usage(parsed) }
148
+ end
149
+
150
+ def safe_parse_json(str)
151
+ JSON.parse(str)
152
+ rescue JSON::ParserError
153
+ {}
154
+ end
155
+
156
+ def extract_text(parsed)
157
+ content = parsed['content'] || parsed[:content]
158
+ return nil unless content.is_a?(Array)
159
+
160
+ content.filter_map { |block| block['text'] || block[:text] }.join
161
+ end
162
+
163
+ def extract_usage(parsed)
164
+ usage = parsed['usage'] || parsed[:usage] || {}
165
+ {
166
+ input_tokens: usage['input_tokens'] || usage[:input_tokens],
167
+ output_tokens: usage['output_tokens'] || usage[:output_tokens]
168
+ }
169
+ end
170
+ end
171
+
172
+ # Accumulates streaming-response state so the adapter can emit a
173
+ # single observation after the stream ends.
174
+ #
175
+ # Bedrock's invoke_model_with_response_stream yields events whose
176
+ # `bytes` is JSON. For Anthropic on Bedrock the relevant event types
177
+ # are:
178
+ #
179
+ # * `content_block_delta` — incremental text in `delta.text`
180
+ # * `message_delta` — top-level `usage.output_tokens` (final count)
181
+ # * `message_stop` — Bedrock adds `amazon-bedrock-invocationMetrics`
182
+ # here with `inputTokenCount` and `outputTokenCount`
183
+ #
184
+ # Both message_delta and message_stop are emitted on success; we
185
+ # prefer message_stop's metrics when both are present.
186
+ class StreamCollector
187
+ attr_reader :text
188
+
189
+ def initialize
190
+ @text = +''
191
+ @input_tokens = nil
192
+ @output_tokens = nil
193
+ end
194
+
195
+ def usage
196
+ { input_tokens: @input_tokens, output_tokens: @output_tokens }
197
+ end
198
+
199
+ def observe(event)
200
+ parsed = parse_event(event)
201
+ return if parsed.nil?
202
+
203
+ case parsed['type']
204
+ when 'message_start'
205
+ # Anthropic emits initial input_tokens here. Used as a
206
+ # fallback when Bedrock's message_stop invocationMetrics
207
+ # are absent (future models, guardrail truncations, etc.).
208
+ start_usage = parsed.dig('message', 'usage') || {}
209
+ @input_tokens ||= start_usage['input_tokens']
210
+ when 'content_block_delta'
211
+ delta_text = parsed.dig('delta', 'text')
212
+ @text << delta_text if delta_text
213
+ when 'message_delta'
214
+ # message_delta usage is cumulative across the (possibly many)
215
+ # message_delta events Anthropic emits, so the latest value
216
+ # wins. See https://docs.anthropic.com/en/api/messages-streaming
217
+ usage = parsed['usage'] || {}
218
+ @output_tokens = usage['output_tokens'] if usage['output_tokens']
219
+ when 'message_stop'
220
+ metrics = parsed['amazon-bedrock-invocationMetrics'] || {}
221
+ @input_tokens = metrics['inputTokenCount'] if metrics['inputTokenCount']
222
+ @output_tokens = metrics['outputTokenCount'] if metrics['outputTokenCount']
223
+ end
224
+ rescue StandardError => e
225
+ OpenTelemetry.handle_error(
226
+ message: "BedrockClaudeAdapter::StreamCollector#observe failed: #{e.class}: #{e.message[0, 200]}"
227
+ )
228
+ end
229
+
230
+ private
231
+
232
+ def parse_event(event)
233
+ bytes = event.respond_to?(:bytes) ? event.bytes : event
234
+ return nil if bytes.nil?
235
+
236
+ JSON.parse(bytes.to_s)
237
+ rescue JSON::ParserError
238
+ nil
239
+ end
240
+ end
241
+ end
242
+ end
243
+ end
244
+ end
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_support/notifications'
4
+
5
+ module PlatformSdk
6
+ module Observability
7
+ module Langfuse
8
+ # Fires `llm_call.platform_sdk` ActiveSupport::Notifications events with
9
+ # a payload extracted from a `ruby-openai` chat call. Apps that hit
10
+ # OpenAI's chat completions API directly (rather than via RubyLLM) call
11
+ # `with_observability` to get cost, tokens, model, input, and output
12
+ # captured in Langfuse:
13
+ #
14
+ # PlatformSdk::Observability::Langfuse::OpenAIAdapter.with_observability(
15
+ # parameters: { model: 'gpt-4o', messages: [...], ... },
16
+ # context: 'amend_json'
17
+ # ) do
18
+ # OpenAI::Client.new.chat(parameters: parameters)
19
+ # end
20
+ #
21
+ # The block runs the actual LLM call and its return value is forwarded
22
+ # back to the caller. On success a generation observation is recorded
23
+ # with model/usage from the response. On raise a failure observation
24
+ # is recorded and the exception is re-raised unchanged.
25
+ #
26
+ # No hard dependency on the `ruby-openai` gem — this adapter only
27
+ # touches the plain-Hash response shape OpenAI's API returns.
28
+ module OpenAIAdapter
29
+ class << self
30
+ # Wrap an `OpenAI::Client#chat` call. Forwards the block's return
31
+ # value. On success: fires success notification with model/input/
32
+ # output/usage extracted from `parameters` and the returned response.
33
+ # On raise: fires failure notification with `error:` set, re-raises.
34
+ def with_observability(parameters:, context:)
35
+ response = yield
36
+ fire(parameters:, response:, context:)
37
+ response
38
+ rescue StandardError => e
39
+ fire(parameters:, response: nil, context:, error: e)
40
+ raise
41
+ end
42
+
43
+ # Fire a single `llm_call.platform_sdk` notification. Useful when
44
+ # the caller has already invoked OpenAI and just wants to record
45
+ # the observation after the fact.
46
+ def fire(parameters:, response:, context:, error: nil)
47
+ payload = build_payload(parameters:, response:, context:, error:)
48
+ return unless payload
49
+
50
+ ActiveSupport::Notifications.instrument(LLM_CALL_EVENT, payload)
51
+ rescue StandardError => e
52
+ OpenTelemetry.handle_error(
53
+ message: "OpenAIAdapter.fire failed: #{e.class}: #{e.message[0, 200]}"
54
+ )
55
+ nil
56
+ end
57
+
58
+ private
59
+
60
+ def build_payload(parameters:, response:, context:, error:)
61
+ return nil unless Langfuse.enabled?
62
+
63
+ parameters ||= {}
64
+ choice = response.is_a?(Hash) ? response.dig('choices', 0, 'message') : nil
65
+ usage = response.is_a?(Hash) ? response['usage'] : nil
66
+
67
+ {
68
+ name: context || 'llm_call',
69
+ model: parameters[:model] || parameters['model'],
70
+ input: parameters[:messages] || parameters['messages'],
71
+ output: choice && choice['content'],
72
+ # ruby-openai responses use prompt_tokens / completion_tokens; the
73
+ # input_tokens / output_tokens fallbacks cover OpenAI-compatible
74
+ # proxies and any future rename of those fields.
75
+ usage: {
76
+ input_tokens: usage && (usage['prompt_tokens'] || usage['input_tokens']),
77
+ output_tokens: usage && (usage['completion_tokens'] || usage['output_tokens'])
78
+ },
79
+ provider: 'openai',
80
+ error:
81
+ }
82
+ end
83
+ end
84
+ end
85
+ end
86
+ end
87
+ end
@@ -24,6 +24,8 @@ require 'platform_sdk/observability/langfuse/traceable'
24
24
  require 'platform_sdk/observability/langfuse/sidekiq_lifecycle'
25
25
  require 'platform_sdk/observability/langfuse/notification_subscriber'
26
26
  require 'platform_sdk/observability/langfuse/ruby_llm_adapter'
27
+ require 'platform_sdk/observability/langfuse/openai_adapter'
28
+ require 'platform_sdk/observability/langfuse/bedrock_claude_adapter'
27
29
 
28
30
  module PlatformSdk
29
31
  module Observability
@@ -2,7 +2,7 @@
2
2
 
3
3
  module PlatformSdk
4
4
  MAJOR = 3
5
- MINOR = 29
5
+ MINOR = 30
6
6
  PATCH = 0
7
7
 
8
8
  VERSION = "#{PlatformSdk::MAJOR}.#{PlatformSdk::MINOR}.#{PlatformSdk::PATCH}"
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: strongmind-platform-sdk
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.29.0
4
+ version: 3.30.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Platform Team
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-05-13 00:00:00.000000000 Z
11
+ date: 2026-05-15 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: faraday
@@ -316,10 +316,12 @@ files:
316
316
  - lib/platform_sdk/logging/pii_formatter.rb
317
317
  - lib/platform_sdk/observability.rb
318
318
  - lib/platform_sdk/observability/langfuse.rb
319
+ - lib/platform_sdk/observability/langfuse/bedrock_claude_adapter.rb
319
320
  - lib/platform_sdk/observability/langfuse/coercions.rb
320
321
  - lib/platform_sdk/observability/langfuse/configuration.rb
321
322
  - lib/platform_sdk/observability/langfuse/notification_subscriber.rb
322
323
  - lib/platform_sdk/observability/langfuse/null_span_exporter.rb
324
+ - lib/platform_sdk/observability/langfuse/openai_adapter.rb
323
325
  - lib/platform_sdk/observability/langfuse/recorder.rb
324
326
  - lib/platform_sdk/observability/langfuse/ruby_llm_adapter.rb
325
327
  - lib/platform_sdk/observability/langfuse/sidekiq_lifecycle.rb