datadog 2.42.0 → 2.44.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.
Files changed (94) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +67 -2
  3. data/README.md +3 -0
  4. data/ext/datadog_profiling_native_extension/collectors_cpu_and_wall_time_worker.c +46 -5
  5. data/ext/datadog_profiling_native_extension/collectors_idle_sampling_helper.c +6 -7
  6. data/ext/datadog_profiling_native_extension/collectors_stack.c +6 -7
  7. data/ext/datadog_profiling_native_extension/collectors_stack.h +1 -1
  8. data/ext/datadog_profiling_native_extension/collectors_thread_context.c +80 -36
  9. data/ext/datadog_profiling_native_extension/collectors_thread_context.h +3 -2
  10. data/ext/datadog_profiling_native_extension/extconf.rb +4 -2
  11. data/ext/datadog_profiling_native_extension/heap_recorder.c +13 -0
  12. data/ext/datadog_profiling_native_extension/heap_recorder.h +4 -0
  13. data/ext/datadog_profiling_native_extension/private_vm_api_access.c +34 -2
  14. data/ext/datadog_profiling_native_extension/private_vm_api_access.h +1 -0
  15. data/ext/datadog_profiling_native_extension/profiling.c +1 -0
  16. data/ext/datadog_profiling_native_extension/stack_recorder.c +17 -4
  17. data/ext/datadog_profiling_native_extension/time_helpers.h +5 -4
  18. data/ext/libdatadog_api/di.c +8 -3
  19. data/ext/libdatadog_api/extconf.rb +1 -1
  20. data/ext/libdatadog_api/feature_flags.c +8 -0
  21. data/ext/libdatadog_api/init.c +3 -1
  22. data/ext/libdatadog_api/otel_thread_context.c +51 -15
  23. data/ext/libdatadog_api/trace_exporter.c +642 -22
  24. data/ext/libdatadog_extconf_helpers.rb +14 -9
  25. data/lib/datadog/ai_guard/component.rb +17 -8
  26. data/lib/datadog/ai_guard/configuration/ext.rb +2 -0
  27. data/lib/datadog/ai_guard/configuration.rb +13 -0
  28. data/lib/datadog/ai_guard/contrib/ruby_llm/chat_instrumentation.rb +48 -55
  29. data/lib/datadog/ai_guard/contrib/ruby_llm/integration.rb +1 -1
  30. data/lib/datadog/ai_guard/contrib/ruby_llm/message_adapter.rb +76 -0
  31. data/lib/datadog/ai_guard/contrib/ruby_llm/patcher.rb +1 -0
  32. data/lib/datadog/ai_guard/contrib/ruby_llm/redaction_change_set.rb +135 -0
  33. data/lib/datadog/ai_guard/evaluation/client.rb +50 -0
  34. data/lib/datadog/ai_guard/evaluation/content_part.rb +12 -0
  35. data/lib/datadog/ai_guard/evaluation/message.rb +25 -7
  36. data/lib/datadog/ai_guard/evaluation/message_builder.rb +31 -0
  37. data/lib/datadog/ai_guard/evaluation/no_op_result.rb +6 -6
  38. data/lib/datadog/ai_guard/evaluation/outcome.rb +24 -0
  39. data/lib/datadog/ai_guard/evaluation/request.rb +4 -60
  40. data/lib/datadog/ai_guard/evaluation/response.rb +33 -0
  41. data/lib/datadog/ai_guard/evaluation/result.rb +11 -18
  42. data/lib/datadog/ai_guard/evaluation/tool_call.rb +15 -2
  43. data/lib/datadog/ai_guard/evaluation.rb +25 -11
  44. data/lib/datadog/ai_guard/ext.rb +1 -0
  45. data/lib/datadog/ai_guard/{api_client.rb → http_client.rb} +6 -4
  46. data/lib/datadog/ai_guard/metrics/telemetry.rb +43 -0
  47. data/lib/datadog/ai_guard/redaction/replacements.rb +97 -0
  48. data/lib/datadog/ai_guard/redaction/result.rb +29 -0
  49. data/lib/datadog/ai_guard/redaction.rb +80 -0
  50. data/lib/datadog/ai_guard.rb +58 -57
  51. data/lib/datadog/core/configuration/components.rb +9 -1
  52. data/lib/datadog/core/configuration/settings.rb +5 -2
  53. data/lib/datadog/core/configuration/supported_configurations.rb +8 -0
  54. data/lib/datadog/core/remote/client/capabilities.rb +10 -4
  55. data/lib/datadog/core/remote/component.rb +9 -2
  56. data/lib/datadog/core/remote/worker.rb +8 -0
  57. data/lib/datadog/di/instrumenter.rb +1 -1
  58. data/lib/datadog/di/probe_manager.rb +10 -5
  59. data/lib/datadog/di/proc_responder.rb +1 -1
  60. data/lib/datadog/open_feature/agentless/configuration_source.rb +246 -0
  61. data/lib/datadog/open_feature/agentless/parser.rb +43 -0
  62. data/lib/datadog/open_feature/agentless/response.rb +19 -0
  63. data/lib/datadog/open_feature/agentless/transport.rb +103 -0
  64. data/lib/datadog/open_feature/component.rb +57 -1
  65. data/lib/datadog/open_feature/configuration/agentless_endpoint.rb +90 -0
  66. data/lib/datadog/open_feature/configuration/source/resolution.rb +22 -0
  67. data/lib/datadog/open_feature/configuration/source.rb +43 -0
  68. data/lib/datadog/open_feature/configuration.rb +118 -0
  69. data/lib/datadog/open_feature/native_evaluator.rb +1 -14
  70. data/lib/datadog/open_feature/remote.rb +5 -5
  71. data/lib/datadog/open_feature/transport.rb +5 -0
  72. data/lib/datadog/opentelemetry/signal_configuration.rb +13 -9
  73. data/lib/datadog/profiling/collectors/cpu_and_wall_time_worker.rb +2 -2
  74. data/lib/datadog/profiling/collectors/idle_sampling_helper.rb +5 -7
  75. data/lib/datadog/profiling/collectors/info.rb +2 -6
  76. data/lib/datadog/symbol_database/extractor.rb +84 -102
  77. data/lib/datadog/tracing/component.rb +2 -0
  78. data/lib/datadog/tracing/configuration/ext.rb +1 -0
  79. data/lib/datadog/tracing/configuration/settings.rb +6 -0
  80. data/lib/datadog/tracing/context.rb +24 -2
  81. data/lib/datadog/tracing/context_provider.rb +9 -5
  82. data/lib/datadog/tracing/contrib/grape/endpoint.rb +32 -5
  83. data/lib/datadog/tracing/contrib/sequel/jdbc_connection_string.rb +140 -0
  84. data/lib/datadog/tracing/contrib/sequel/utils.rb +49 -62
  85. data/lib/datadog/tracing/metadata/ext.rb +3 -0
  86. data/lib/datadog/tracing/otel_thread_context.rb +78 -9
  87. data/lib/datadog/tracing/tracer.rb +15 -2
  88. data/lib/datadog/tracing/transport/native.rb +18 -26
  89. data/lib/datadog/tracing/transport/span_events_negotiation.rb +41 -0
  90. data/lib/datadog/tracing/transport/trace_formatter.rb +13 -3
  91. data/lib/datadog/tracing/transport/traces.rb +3 -23
  92. data/lib/datadog/version.rb +1 -1
  93. metadata +25 -7
  94. data/lib/datadog/ai_guard/evaluation/content_builder.rb +0 -31
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "rubygems"
4
4
  require "pathname"
5
+ require "shellwords"
5
6
 
6
7
  module Datadog
7
8
  # Contains a bunch of shared helpers that get used during building of extensions that link to libdatadog
@@ -10,7 +11,7 @@ module Datadog
10
11
  module LibdatadogExtconfHelpers
11
12
  # Used to make sure the correct gem version gets loaded, as extconf.rb does not get run with "bundle exec" and thus
12
13
  # may see multiple libdatadog versions. See https://github.com/DataDog/dd-trace-rb/pull/2531 for the horror story.
13
- LIBDATADOG_VERSION = "~> 40.0.0.2.0"
14
+ LIBDATADOG_VERSION = "~> 44.0.1.1.0"
14
15
 
15
16
  # Used as an workaround for a limitation with how dynamic linking works in environments where the datadog gem and
16
17
  # libdatadog are moved after the extension gets compiled.
@@ -116,7 +117,10 @@ module Datadog
116
117
  extconf_folder:,
117
118
  libdatadog_pkgconfig_folder: Libdatadog.pkgconfig_folder,
118
119
  gem_dir: Gem.dir,
119
- logger: Logging
120
+ logger: Logging,
121
+ target_incflags: $INCFLAGS,
122
+ target_ldflags: $LDFLAGS,
123
+ target_libs: $libs
120
124
  )
121
125
  return unless libdatadog_pkgconfig_folder
122
126
 
@@ -124,10 +128,9 @@ module Datadog
124
128
  libdir = "#{libdatadog_pkgconfig_folder}/../../lib"
125
129
  includedir = "#{libdatadog_pkgconfig_folder}/../../include"
126
130
 
127
- # Set mkmf global variables
128
- $INCFLAGS << " -I#{includedir}"
129
- $LDFLAGS << " -L#{libdir} -Wl,-rpath,#{libdir}"
130
- $libs << " -ldatadog_profiling"
131
+ target_incflags << " -I#{Shellwords.escape(includedir)}"
132
+ target_ldflags << " -L#{Shellwords.escape(libdir)} -Wl,-rpath,#{Shellwords.escape(libdir)}"
133
+ target_libs << " -ldatadog_profiling"
131
134
 
132
135
  # Add extra relative rpaths using $ORIGIN to handle environments where gems are moved after installation.
133
136
  # The excessive escaping is needed to get these special characters through Make and the shell untouched.
@@ -141,10 +144,12 @@ module Datadog
141
144
  libdatadog_pkgconfig_folder: libdatadog_pkgconfig_folder,
142
145
  ),
143
146
  ]
144
- extra_relative_rpaths.each { |folder| $LDFLAGS << " -Wl,-rpath,$$$\\\\{ORIGIN\\}/#{folder}" }
147
+ extra_relative_rpaths.each { |folder| target_ldflags << " -Wl,-rpath,$$$\\\\{ORIGIN\\}/#{Shellwords.escape(folder)}" }
145
148
 
146
- logger.message("linking with libdatadog (include=#{includedir}, lib=#{libdir})\n")
147
- logger.message("[datadog] $LDFLAGS were set to: #{$LDFLAGS.inspect}\n")
149
+ # includedir/libdir/ldflags might have a `%` in there and `logger.message` bottoms out on `printf` so we need to
150
+ # use the `message("%s", ...)` format otherwise the logger will complain
151
+ logger.message("%s", "linking with libdatadog (include=#{includedir}, lib=#{libdir})\n")
152
+ logger.message("%s", "[datadog] $LDFLAGS were set to: #{target_ldflags.inspect}\n")
148
153
 
149
154
  true
150
155
  end
@@ -1,37 +1,46 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "api_client"
3
+ require "json"
4
+
5
+ require_relative "ext"
6
+ require_relative "http_client"
4
7
  require_relative "evaluation"
5
8
  require_relative "evaluation/request"
9
+ require_relative "evaluation/response"
10
+ require_relative "evaluation/client"
6
11
  require_relative "evaluation/result"
12
+ require_relative "evaluation/outcome"
7
13
  require_relative "evaluation/no_op_result"
8
14
  require_relative "evaluation/message"
9
15
  require_relative "evaluation/tool_call"
10
16
  require_relative "evaluation/content_part"
11
- require_relative "evaluation/content_builder"
12
- require_relative "ext"
17
+ require_relative "evaluation/message_builder"
18
+ require_relative "redaction"
19
+ require_relative "redaction/result"
20
+ require_relative "redaction/replacements"
21
+ require_relative "metrics/telemetry"
13
22
 
14
23
  module Datadog
15
24
  module AIGuard
16
25
  # Component for API Guard product
17
26
  class Component
18
- attr_reader :api_client, :logger, :telemetry
27
+ attr_reader :http_client, :logger, :telemetry
19
28
 
20
29
  def self.build(settings, logger:, telemetry:)
21
30
  return unless settings.respond_to?(:ai_guard) && settings.ai_guard.enabled
22
31
 
23
- api_client = APIClient.new(
32
+ http_client = HTTPClient.new(
24
33
  endpoint: settings.ai_guard.endpoint,
25
34
  api_key: settings.api_key,
26
35
  application_key: settings.ai_guard.app_key,
27
36
  timeout: settings.ai_guard.timeout_ms / 1_000
28
37
  )
29
38
 
30
- new(api_client, logger: logger, telemetry: telemetry)
39
+ new(http_client, logger: logger, telemetry: telemetry)
31
40
  end
32
41
 
33
- def initialize(api_client, logger:, telemetry:)
34
- @api_client = api_client
42
+ def initialize(http_client, logger:, telemetry:)
43
+ @http_client = http_client
35
44
  @logger = logger
36
45
  @telemetry = telemetry
37
46
  end
@@ -6,6 +6,8 @@ module Datadog
6
6
  # This module contains constants for AI Guard component
7
7
  module Ext
8
8
  ENV_AI_GUARD_ENABLED = "DD_AI_GUARD_ENABLED"
9
+ ENV_AI_GUARD_REDACTION_ENABLED = "DD_AI_GUARD_REDACTION_ENABLED"
10
+
9
11
  ENV_AI_GUARD_ENDPOINT = "DD_AI_GUARD_ENDPOINT"
10
12
  ENV_AI_GUARD_TIMEOUT = "DD_AI_GUARD_TIMEOUT"
11
13
  ENV_AI_GUARD_MAX_CONTENT_SIZE = "DD_AI_GUARD_MAX_CONTENT_SIZE"
@@ -34,6 +34,19 @@ module Datadog
34
34
  o.default false
35
35
  end
36
36
 
37
+ # Redact sensitive data from evaluated messages.
38
+ #
39
+ # When enabled, values flagged by AI Guard are replaced before the messages are
40
+ # stored in the span or returned to the caller. Disable to skip redaction entirely.
41
+ #
42
+ # @default `DD_AI_GUARD_REDACTION_ENABLED`, otherwise `true`
43
+ # @return [Boolean]
44
+ option :redaction_enabled do |o|
45
+ o.type :bool
46
+ o.env Ext::ENV_AI_GUARD_REDACTION_ENABLED
47
+ o.default true
48
+ end
49
+
37
50
  define_method(:instrument) do |integration_name|
38
51
  return unless enabled # steep:ignore
39
52
 
@@ -4,74 +4,67 @@ module Datadog
4
4
  module AIGuard
5
5
  module Contrib
6
6
  module RubyLLM
7
- # module that gets prepended to RubyLLM::Chat
7
+ # Protects tool calls before RubyLLM executes them locally
8
+ #
9
+ # @api private
8
10
  module ChatInstrumentation
9
- class << self
10
- def evaluate!(messages)
11
- ai_guard_messages = messages.flat_map do |message|
12
- if message.tool_call?
13
- message.tool_calls.map do |tool_call_id, tool_call|
14
- AIGuard.assistant(id: tool_call_id, tool_name: tool_call.name, arguments: tool_call.arguments.to_s)
15
- end
16
- elsif message.tool_result?
17
- build_ai_guard_tool(message)
18
- else
19
- build_ai_guard_message(message)
20
- end
21
- end
11
+ private
22
12
 
23
- AIGuard.evaluate(*ai_guard_messages)
24
- end
25
-
26
- private
13
+ def provider_completion(usage_recorder:, stream_tracker: nil, &block)
14
+ @_datadog_ai_guard_evaluate = true
15
+ super
16
+ ensure
17
+ @_datadog_ai_guard_evaluate = false
18
+ end
27
19
 
28
- def build_ai_guard_message(message)
29
- content = message.content
20
+ def preprocessed_messages(list = messages)
21
+ return super unless @_datadog_ai_guard_evaluate
30
22
 
31
- case content
32
- when ::RubyLLM::Content
33
- AIGuard.message(role: message.role) do |m|
34
- m.text(content.text.to_s) if content.text
23
+ adapter = MessageAdapter.new(list)
35
24
 
36
- # Calling attachment.for_llm triggers lazy loading of file contents.
37
- # The result is memoized, so providers won't re-read.
38
- content.attachments.each do |attachment|
39
- case attachment.type
40
- when :image
41
- m.image_url(attachment.for_llm)
42
- when :text
43
- m.text(attachment.for_llm)
44
- end
45
- # Skip :pdf, :audio, :video, :unknown — not supported by AIGuard
46
- end
47
- end
48
- else
49
- AIGuard.message(role: message.role, content: content)
50
- end
25
+ begin
26
+ converted_messages = adapter.to_ai_guard
27
+ rescue JSON::JSONError
28
+ Metrics::Telemetry.report_error
29
+ return super
30
+ rescue => e
31
+ AIGuard.telemetry&.report(e, description: "AI Guard: Failed to convert RubyLLM messages")
32
+ return super
51
33
  end
52
34
 
53
- def build_ai_guard_tool(message)
54
- content = message.content
55
- # Tools can return Content or Content::Raw objects (e.g. with attachments),
56
- # but AIGuard.tool expects a String. Extract text when content is a Content object.
57
- case content
58
- when ::RubyLLM::Content
59
- content = content.text.to_s
60
- end
61
- AIGuard.tool(tool_call_id: message.tool_call_id, content: content)
35
+ evaluation = AIGuard.evaluate(*converted_messages)
36
+
37
+ begin
38
+ redacted_messages = adapter.apply_redactions(evaluation.messages)
39
+ rescue JSON::JSONError => e
40
+ AIGuard.telemetry&.report(e, description: "AI Guard: Failed to apply RubyLLM redaction")
41
+ return super
62
42
  end
43
+
44
+ super(redacted_messages)
63
45
  end
64
46
 
65
- def complete(&block)
66
- Datadog::AIGuard::Contrib::RubyLLM::ChatInstrumentation.evaluate!(messages)
47
+ def execute_pending_tool_calls(response)
48
+ response_index = messages.index { |message| message == response }
49
+ return super unless response_index
67
50
 
68
- super
69
- end
51
+ adapter = MessageAdapter.new(messages)
52
+ begin
53
+ converted_messages = adapter.to_ai_guard
54
+ rescue JSON::JSONError
55
+ Metrics::Telemetry.report_error
56
+ return super
57
+ end
70
58
 
71
- def handle_tool_calls(response, &block)
72
- Datadog::AIGuard::Contrib::RubyLLM::ChatInstrumentation.evaluate!(messages)
59
+ evaluation = AIGuard.evaluate(*converted_messages)
60
+ begin
61
+ redacted_messages = adapter.apply_redactions(evaluation.messages)
62
+ rescue JSON::JSONError => e
63
+ AIGuard.telemetry&.report(e, description: "AI Guard: Failed to apply RubyLLM redaction")
64
+ return super
65
+ end
73
66
 
74
- super
67
+ super(redacted_messages[response_index])
75
68
  end
76
69
  end
77
70
  end
@@ -11,7 +11,7 @@ module Datadog
11
11
  class Integration
12
12
  include Datadog::AIGuard::Contrib::Integration
13
13
 
14
- MINIMUM_VERSION = Gem::Version.new("1.0.0")
14
+ MINIMUM_VERSION = Gem::Version.new("2.0.0")
15
15
 
16
16
  register_as :ruby_llm, auto_patch: false
17
17
 
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "redaction_change_set"
4
+
5
+ module Datadog
6
+ module AIGuard
7
+ module Contrib
8
+ module RubyLLM
9
+ # RubyLLM messages paired with their AI Guard representation
10
+ #
11
+ # @api private
12
+ class MessageAdapter
13
+ def initialize(messages)
14
+ @ruby_llm_messages = messages
15
+ end
16
+
17
+ def to_ai_guard
18
+ @ai_guard_messages ||= @ruby_llm_messages.map { |message| build_ai_guard_message(message) }
19
+ end
20
+
21
+ def apply_redactions(redacted_messages)
22
+ ai_guard_messages = to_ai_guard
23
+ return @ruby_llm_messages if redacted_messages == ai_guard_messages
24
+
25
+ @ruby_llm_messages.each_with_index.map do |message, index|
26
+ original_message = ai_guard_messages[index]
27
+ redacted_message = redacted_messages[index]
28
+ next message if redacted_message == original_message
29
+
30
+ RedactionChangeSet.new(original_message, redacted_message).apply_to(message)
31
+ end
32
+ end
33
+
34
+ private
35
+
36
+ def build_ai_guard_message(message)
37
+ builder = Evaluation::MessageBuilder.new
38
+
39
+ # NOTE: Hash block arguments are misinterpreted by Steep after `nil.to_h`
40
+ # steep:ignore:start
41
+ message.tool_calls.to_h.each do |id, tool_call|
42
+ builder.tool_call(name: tool_call.name, id: id, arguments: tool_call.arguments)
43
+ end
44
+ # steep:ignore:end
45
+
46
+ if message.attachments.empty?
47
+ content = message.content
48
+ else
49
+ builder.text(message.content) if message.content && !message.content.empty?
50
+
51
+ message.attachments.each do |attachment|
52
+ next if attachment.provider_file?
53
+
54
+ case attachment.type
55
+ when :text
56
+ builder.text(attachment.content)
57
+ when :image
58
+ # NOTE: Local images are Base64-encoded here by `#for_llm`
59
+ # and again when RubyLLM builds the provider request
60
+ url = attachment.url? ? attachment.source.to_s : attachment.for_llm
61
+ builder.image_url(url)
62
+ end
63
+ end
64
+
65
+ content = builder.content
66
+ end
67
+
68
+ Evaluation::Message.new(
69
+ role: message.role, content: content, tool_calls: builder.tool_calls, tool_call_id: message.tool_call_id
70
+ )
71
+ end
72
+ end
73
+ end
74
+ end
75
+ end
76
+ end
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "message_adapter"
3
4
  require_relative "chat_instrumentation"
4
5
 
5
6
  module Datadog
@@ -0,0 +1,135 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "stringio"
5
+
6
+ module Datadog
7
+ module AIGuard
8
+ module Contrib
9
+ module RubyLLM
10
+ # Changes required to redact a RubyLLM message
11
+ #
12
+ # @api private
13
+ class RedactionChangeSet
14
+ def initialize(original_message, redacted_message)
15
+ @content_replacement = content_replacement(original_message.content, redacted_message.content)
16
+ @content_part_replacements = content_part_replacements(original_message.content, redacted_message.content)
17
+ @tool_call_replacements = tool_call_replacements(original_message.tool_calls, redacted_message.tool_calls)
18
+ end
19
+
20
+ def apply_to(message)
21
+ return message unless changed?
22
+
23
+ # NOTE: Preserve `raw_content` after provider-specific redaction is implemented.
24
+ # Unchanged raw content could bypass redaction
25
+ if message.raw_content
26
+ Datadog.logger.warn("AI Guard omitted RubyLLM raw content because it may contain sensitive data")
27
+ end
28
+
29
+ content = build_content(message)
30
+ attachments = build_attachments(message)
31
+ tool_calls = build_tool_calls(message)
32
+
33
+ changed_message = ::RubyLLM::Message.new(
34
+ content: content, attachments: attachments, tool_calls: tool_calls,
35
+ role: message.role, model: message.model, tool_call_id: message.tool_call_id,
36
+ raw: message.raw, thinking: message.thinking, citations: message.citations,
37
+ finish_reason: message.finish_reason, server_tool_calls: message.server_tool_calls,
38
+ raw_reasoning: message.raw_reasoning, cache_until_here: message.cache_until_here?
39
+ )
40
+ changed_message.conversation = message.conversation
41
+ changed_message
42
+ end
43
+
44
+ private
45
+
46
+ # NOTE: Steep treats original and redacted content shapes as independent
47
+ def content_replacement(original_content, redacted_content) # steep:ignore MethodBodyTypeMismatch
48
+ return if original_content.is_a?(Array) || redacted_content == original_content
49
+
50
+ redacted_content
51
+ end
52
+
53
+ def content_part_replacements(original_content, redacted_content)
54
+ return {} unless original_content.is_a?(Array)
55
+
56
+ replacements = {}
57
+ original_content.each_with_index do |content_part, index|
58
+ next if content_part == redacted_content[index] # steep:ignore NoMethod
59
+
60
+ replacements[index] = redacted_content[index] # steep:ignore NoMethod
61
+ end
62
+
63
+ replacements
64
+ end
65
+
66
+ def tool_call_replacements(original_tool_calls, redacted_tool_calls)
67
+ replacements = {}
68
+ original_tool_calls.each_with_index do |tool_call, index|
69
+ next if redacted_tool_calls[index] == tool_call
70
+
71
+ replacements[tool_call.id] = redacted_tool_calls[index]
72
+ end
73
+
74
+ replacements
75
+ end
76
+
77
+ def changed?
78
+ !!@content_replacement || !@content_part_replacements.empty? ||
79
+ !@tool_call_replacements.empty?
80
+ end
81
+
82
+ def build_content(message)
83
+ return @content_replacement if @content_replacement
84
+ return message.content if message.content.to_s.empty?
85
+
86
+ replacement = @content_part_replacements[0]
87
+ replacement ? replacement.text : message.content
88
+ end
89
+
90
+ def build_attachments(message)
91
+ return message.attachments if @content_part_replacements.empty?
92
+
93
+ content_part_index = message.content.to_s.empty? ? 0 : 1
94
+ # @type var attachments: Array[::RubyLLM::Attachment]?
95
+ attachments = nil
96
+
97
+ message.attachments.each_with_index do |attachment, attachment_index|
98
+ next if attachment.provider_file?
99
+ next unless attachment.type == :text || attachment.type == :image
100
+
101
+ replacement = @content_part_replacements[content_part_index]
102
+ content_part_index += 1
103
+
104
+ next unless replacement && attachment.type == :text
105
+
106
+ attachments ||= message.attachments.dup
107
+ attachments[attachment_index] = ::RubyLLM::Attachment.new( # steep:ignore NoMethod
108
+ StringIO.new(replacement.text), filename: attachment.filename, config: attachment.config
109
+ )
110
+ end
111
+
112
+ attachments || message.attachments
113
+ end
114
+
115
+ def build_tool_calls(message)
116
+ return message.tool_calls if @tool_call_replacements.empty?
117
+
118
+ # @type var tool_calls: Hash[String, ::RubyLLM::ToolCall]
119
+ tool_calls = message.tool_calls.dup # steep:ignore IncompatibleAssignment
120
+ @tool_call_replacements.each do |id, replacement|
121
+ tool_call = tool_calls.fetch(id)
122
+ tool_calls[id] = ::RubyLLM::ToolCall.new(
123
+ id: tool_call.id, name: tool_call.name, remote: tool_call.remote?,
124
+ thought_signature: tool_call.thought_signature,
125
+ arguments: JSON.parse(replacement.arguments)
126
+ )
127
+ end
128
+
129
+ tool_calls
130
+ end
131
+ end
132
+ end
133
+ end
134
+ end
135
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Datadog
4
+ module AIGuard
5
+ module Evaluation
6
+ # Client for evaluation API requests
7
+ #
8
+ # @api private
9
+ module Client
10
+ def self.evaluate(messages)
11
+ request = Request.new(messages)
12
+ http_client = AIGuard.http_client
13
+
14
+ # This should never happen, as we are only calling this method when AI Guard is enabled,
15
+ # and this means the HTTP client was not initialized properly.
16
+ #
17
+ # Please report this at https://github.com/datadog/dd-trace-rb/blob/master/CONTRIBUTING.md#found-a-bug
18
+ raise "AI Guard HTTP client not initialized" unless http_client
19
+
20
+ response = Response.new(
21
+ http_client.post(Request::REQUEST_PATH, body: request.body)
22
+ )
23
+
24
+ redaction =
25
+ if Datadog.configuration.ai_guard.redaction_enabled
26
+ Redaction.perform(messages, replacements: response.redaction_replacements)
27
+ else
28
+ Redaction.skip(messages)
29
+ end
30
+
31
+ result = Result.new(
32
+ redaction.messages,
33
+ action: response.action,
34
+ reason: response.reason,
35
+ tags: response.tags,
36
+ sds_findings: response.sds_findings,
37
+ tag_probabilities: response.tag_probabilities,
38
+ redaction_replacements: response.redaction_replacements
39
+ )
40
+
41
+ Outcome.new(
42
+ result: result,
43
+ redaction: redaction,
44
+ blocking_enabled: response.blocking_enabled?
45
+ )
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
@@ -16,6 +16,14 @@ module Datadog
16
16
  def type
17
17
  :text
18
18
  end
19
+
20
+ def copy(text:)
21
+ Text.new(text)
22
+ end
23
+
24
+ def to_h
25
+ {type: type.to_s, text: text}
26
+ end
19
27
  end
20
28
 
21
29
  # An image URL content part. Accepts an absolute URL or a base64 data URI.
@@ -29,6 +37,10 @@ module Datadog
29
37
  def type
30
38
  :image_url
31
39
  end
40
+
41
+ def to_h
42
+ {type: type.to_s, image_url: {url: url}}
43
+ end
32
44
  end
33
45
  end
34
46
  end
@@ -3,21 +3,39 @@
3
3
  module Datadog
4
4
  module AIGuard
5
5
  module Evaluation
6
- # Message class for AI Guard
6
+ # Represents a message submitted for evaluation
7
7
  class Message
8
- attr_reader :role, :content, :tool_call, :tool_call_id
8
+ attr_reader :role, :content, :tool_calls, :tool_call_id
9
9
 
10
- def initialize(role:, content: nil, tool_call: nil, tool_call_id: nil)
10
+ def initialize(role:, content: nil, tool_calls: [], tool_call_id: nil)
11
11
  raise ArgumentError, "Role must be set to a non-empty value" if role.to_s.empty?
12
+ raise ArgumentError, "Tool calls must be an Array" unless tool_calls.is_a?(::Array)
13
+
14
+ if tool_calls.any? { |tool_call| !tool_call.is_a?(ToolCall) }
15
+ raise ArgumentError, "Tool calls must contain only #{ToolCall.name} instances"
16
+ end
12
17
 
13
18
  @role = role.to_sym
14
19
  @content = content
15
- @tool_call = tool_call
20
+ @tool_calls = tool_calls
16
21
  @tool_call_id = tool_call_id
22
+ end
17
23
 
18
- if @tool_call && !@tool_call.is_a?(ToolCall)
19
- raise ArgumentError, "Expected an instance of #{ToolCall.name} for :tool_call argument"
20
- end
24
+ def copy(content: self.content, tool_calls: self.tool_calls)
25
+ Message.new(role: role, content: content, tool_calls: tool_calls, tool_call_id: tool_call_id)
26
+ end
27
+
28
+ def to_h
29
+ serialized = {
30
+ role: role,
31
+ content: content.is_a?(::Array) ? content.map(&:to_h) : content,
32
+ tool_call_id: tool_call_id,
33
+ }
34
+
35
+ serialized[:tool_calls] = tool_calls.map(&:to_h) unless tool_calls.empty?
36
+ serialized.compact!
37
+
38
+ serialized
21
39
  end
22
40
  end
23
41
  end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Datadog
4
+ module AIGuard
5
+ module Evaluation
6
+ # Builds a message from content parts and tool calls
7
+ #
8
+ # @public_api
9
+ class MessageBuilder
10
+ attr_reader :content, :tool_calls
11
+
12
+ def initialize
13
+ @content = []
14
+ @tool_calls = []
15
+ end
16
+
17
+ def text(text)
18
+ @content << ContentPart::Text.new(text)
19
+ end
20
+
21
+ def image_url(url)
22
+ @content << ContentPart::ImageURL.new(url)
23
+ end
24
+
25
+ def tool_call(name:, id:, arguments:)
26
+ @tool_calls << ToolCall.new(name, id: id.to_s, arguments: arguments)
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end