nitro_intelligence 4.0.0 → 4.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.
Files changed (55) hide show
  1. checksums.yaml +4 -4
  2. data/.yardopts +9 -0
  3. data/Rakefile +21 -1
  4. data/docs/ASSISTANTS.md +594 -0
  5. data/docs/CHANGELOG.md +243 -0
  6. data/docs/README.md +140 -4
  7. data/lib/nitro_intelligence/assistant.rb +70 -1
  8. data/lib/nitro_intelligence/assistant_registry.rb +18 -0
  9. data/lib/nitro_intelligence/assistants.rb +146 -24
  10. data/lib/nitro_intelligence/client/base.rb +88 -8
  11. data/lib/nitro_intelligence/client/client.rb +20 -0
  12. data/lib/nitro_intelligence/client/factory.rb +1 -0
  13. data/lib/nitro_intelligence/client/handlers/audio_transcription_handler.rb +3 -1
  14. data/lib/nitro_intelligence/client/handlers/base_handler.rb +17 -2
  15. data/lib/nitro_intelligence/client/handlers/chat_handler.rb +3 -1
  16. data/lib/nitro_intelligence/client/handlers/decision_handler.rb +75 -0
  17. data/lib/nitro_intelligence/client/handlers/image_handler.rb +3 -1
  18. data/lib/nitro_intelligence/client/handlers/observed/audio_transcription_handler.rb +30 -12
  19. data/lib/nitro_intelligence/client/handlers/observed/chat_handler.rb +16 -3
  20. data/lib/nitro_intelligence/client/handlers/observed/decision_handler.rb +72 -0
  21. data/lib/nitro_intelligence/client/handlers/observed/image_handler.rb +20 -5
  22. data/lib/nitro_intelligence/client/handlers/observed/text_to_speech_handler.rb +27 -9
  23. data/lib/nitro_intelligence/client/handlers/text_to_speech_handler.rb +3 -1
  24. data/lib/nitro_intelligence/client/observed.rb +8 -0
  25. data/lib/nitro_intelligence/client/observers/langfuse_observer.rb +35 -7
  26. data/lib/nitro_intelligence/configuration.rb +46 -0
  27. data/lib/nitro_intelligence/decision.rb +105 -0
  28. data/lib/nitro_intelligence/langfuse_extension.rb +1 -0
  29. data/lib/nitro_intelligence/langfuse_tracer_provider.rb +7 -6
  30. data/lib/nitro_intelligence/media/audio.rb +6 -0
  31. data/lib/nitro_intelligence/media/image.rb +14 -1
  32. data/lib/nitro_intelligence/media/image_generation.rb +14 -2
  33. data/lib/nitro_intelligence/media/media.rb +4 -0
  34. data/lib/nitro_intelligence/models/model.rb +7 -0
  35. data/lib/nitro_intelligence/models/model_catalog.rb +3 -0
  36. data/lib/nitro_intelligence/models/model_factory.rb +2 -0
  37. data/lib/nitro_intelligence/null_cache.rb +2 -0
  38. data/lib/nitro_intelligence/observability/project.rb +41 -2
  39. data/lib/nitro_intelligence/observability/project_client.rb +10 -1
  40. data/lib/nitro_intelligence/observability/project_client_registry.rb +18 -0
  41. data/lib/nitro_intelligence/observability/prompt.rb +4 -3
  42. data/lib/nitro_intelligence/observability/prompt_resolver.rb +2 -0
  43. data/lib/nitro_intelligence/observability/prompt_store.rb +9 -0
  44. data/lib/nitro_intelligence/observability/upload_handler.rb +3 -1
  45. data/lib/nitro_intelligence/pending_review.rb +36 -1
  46. data/lib/nitro_intelligence/railtie.rb +1 -0
  47. data/lib/nitro_intelligence/reporter.rb +29 -0
  48. data/lib/nitro_intelligence/review_decision.rb +2 -0
  49. data/lib/nitro_intelligence/run_result.rb +31 -0
  50. data/lib/nitro_intelligence/tool_call_review_interrupt.rb +2 -0
  51. data/lib/nitro_intelligence/tool_call_review_validator.rb +2 -0
  52. data/lib/nitro_intelligence/trace.rb +8 -0
  53. data/lib/nitro_intelligence/version.rb +2 -1
  54. data/lib/nitro_intelligence.rb +37 -0
  55. metadata +7 -1
@@ -2,7 +2,9 @@ require "json"
2
2
 
3
3
  module NitroIntelligence
4
4
  module Client
5
+ # @api private
5
6
  module Handlers
7
+ # @api private
6
8
  class BaseHandler
7
9
  MODALITY_HEADER = "nip-modality".freeze
8
10
  REQUESTED_MODEL_HEADER = "nip-requested-model".freeze
@@ -12,6 +14,10 @@ module NitroIntelligence
12
14
  TRACE_ID_HEADER = "x-litellm-trace-id".freeze
13
15
  SPEND_LOGS_METADATA_HEADER = "x-litellm-spend-logs-metadata".freeze
14
16
 
17
+ # Asks the gateway to replace the messages and the response with
18
+ # "redacted-by-litellm" in its own logs. The gateway reads any value as on.
19
+ MESSAGE_REDACTION_HEADER = "x-litellm-enable-message-redaction".freeze
20
+
15
21
  # Headers over ~8KB are rejected by most proxies. Metadata is caller
16
22
  # supplied, so cap it rather than turning a large hash into a failed request.
17
23
  MAX_SPEND_LOGS_METADATA_BYTES = 4096
@@ -82,9 +88,12 @@ module NitroIntelligence
82
88
  nil
83
89
  end
84
90
 
91
+ # Copies the request options rather than changing them, because a caller may pass one
92
+ # hash to several requests, and the headers added here belong to this request alone.
85
93
  def add_request_headers(parameters, headers)
86
- request_options = (parameters[:request_options] ||= {})
87
- (request_options[:extra_headers] ||= {}).merge!(headers.compact)
94
+ request_options = parameters[:request_options].to_h
95
+ parameters[:request_options] =
96
+ request_options.merge(extra_headers: request_options[:extra_headers].to_h.merge(headers.compact))
88
97
  parameters
89
98
  end
90
99
 
@@ -110,6 +119,12 @@ module NitroIntelligence
110
119
  )
111
120
  end
112
121
 
122
+ # A project that masks content in Cerebro keeps it out of the gateway's logs
123
+ # too, so one setting decides where a request's content is recorded.
124
+ def add_message_redaction_header(parameters, mask_content:)
125
+ add_request_headers(parameters, MESSAGE_REDACTION_HEADER => ("true" if mask_content))
126
+ end
127
+
113
128
  def spend_logs_metadata(metadata)
114
129
  return nil if metadata.blank?
115
130
 
@@ -4,6 +4,7 @@ require "nitro_intelligence/client/handlers/base_handler"
4
4
  module NitroIntelligence
5
5
  module Client
6
6
  module Handlers
7
+ # @api private
7
8
  class ChatHandler < BaseHandler
8
9
  ALLOWED_EXTRA_PARAMETERS = OpenAI::Models::Chat::CompletionCreateParams.fields.keys.uniq.freeze
9
10
 
@@ -12,9 +13,10 @@ module NitroIntelligence
12
13
  perform_request(parameters:)
13
14
  end
14
15
 
15
- def perform_request(parameters: {}, correlation_trace_id: nil)
16
+ def perform_request(parameters: {}, correlation_trace_id: nil, mask_content: false)
16
17
  add_request_headers(parameters, REQUESTED_MODEL_HEADER => parameters[:model])
17
18
  add_correlation_headers(parameters, trace_id: correlation_trace_id)
19
+ add_message_redaction_header(parameters, mask_content:)
18
20
  @client.chat.completions.create(**parameters.slice(*ALLOWED_EXTRA_PARAMETERS))
19
21
  end
20
22
 
@@ -0,0 +1,75 @@
1
+ require "openai"
2
+ require "nitro_intelligence/client/handlers/base_handler"
3
+ require "nitro_intelligence/decision"
4
+
5
+ module NitroIntelligence
6
+ module Client
7
+ module Handlers
8
+ # Asks a decision model questions about some content over the gateway's System One route,
9
+ # `POST /v1/systemone`. openai-ruby has no resource for it, so the request goes through
10
+ # the client's generic `request`, which keeps its credentials, retries, timeouts and errors.
11
+ #
12
+ # @api private
13
+ class DecisionHandler < BaseHandler
14
+ # `/v1/systemone` is the route the gateway's docs name and key route limits list.
15
+ PATH = "v1/systemone".freeze
16
+ # What is left of that route to add to an `inference_base_url` that already ends in `/v1`.
17
+ PATH_AFTER_V1 = "systemone".freeze
18
+
19
+ # System One request fields other than the model, state and questions, taken from
20
+ # `parameters`. Everything else there is for the client, the gateway or tracing.
21
+ BODY_PARAMETERS = %i[assets].freeze
22
+
23
+ # Declaring a model, rather than taking the decoded hash, is what gets the response its
24
+ # `last_response`, and with it the gateway's cost headers. Only `model` is declared:
25
+ # `Decision` reads `answers` and `usage` raw through `BaseModel#[]`, which openai-ruby
26
+ # documents as public API for keys outside a model's spec, from 0.86 through 0.99 at least.
27
+ class Response < OpenAI::BaseModel
28
+ required :model, String
29
+ end
30
+
31
+ def create(state:, questions:, parameters: {})
32
+ validate_and_resolve!(parameters)
33
+ perform_request(state:, questions:, parameters:)
34
+ end
35
+
36
+ def perform_request(state:, questions:, parameters: {}, correlation_trace_id: nil, mask_content: false)
37
+ add_request_headers(parameters, REQUESTED_MODEL_HEADER => parameters[:model])
38
+ add_correlation_headers(parameters, trace_id: correlation_trace_id)
39
+ add_message_redaction_header(parameters, mask_content:)
40
+ response = @client.request(
41
+ method: :post,
42
+ path:,
43
+ body: { model: parameters[:model], state:, questions:, **parameters.slice(*BODY_PARAMETERS).compact },
44
+ model: Response,
45
+ options: parameters[:request_options]
46
+ )
47
+ Decision.new(response)
48
+ end
49
+
50
+ def validate_and_resolve!(parameters)
51
+ default_parameters = {
52
+ metadata: {},
53
+ model: NitroIntelligence.model_catalog.default_decision_model&.name,
54
+ }
55
+
56
+ parameters.replace(default_parameters.merge(parameters))
57
+ Client.validate_model(parameters[:model])
58
+ return if NitroIntelligence.model_catalog.lookup_by_name(parameters[:model]).is_a?(DecisionModel)
59
+
60
+ # A decision request has nothing in common with a chat one, so a model of another type
61
+ # would fail at the gateway with an error that does not say why.
62
+ raise ArgumentError, "Model '#{parameters[:model]}' is not a decision model"
63
+ end
64
+
65
+ private
66
+
67
+ # Both reach `/v1/systemone`: the whole route on a base URL without a version, or only the
68
+ # rest of it on one that already ends in `/v1`.
69
+ def path
70
+ @client.base_url.path.chomp("/").end_with?("/v1") ? PATH_AFTER_V1 : PATH
71
+ end
72
+ end
73
+ end
74
+ end
75
+ end
@@ -5,6 +5,7 @@ require "nitro_intelligence/media/image_generation"
5
5
  module NitroIntelligence
6
6
  module Client
7
7
  module Handlers
8
+ # @api private
8
9
  class ImageHandler < BaseHandler
9
10
  ALLOWED_EXTRA_PARAMETERS = OpenAI::Models::Chat::CompletionCreateParams.fields.keys.uniq.freeze
10
11
 
@@ -19,9 +20,10 @@ module NitroIntelligence
19
20
  image_generation
20
21
  end
21
22
 
22
- def perform_request(parameters: {}, correlation_trace_id: nil)
23
+ def perform_request(parameters: {}, correlation_trace_id: nil, mask_content: false)
23
24
  add_request_headers(parameters, MODALITY_HEADER => "image", REQUESTED_MODEL_HEADER => parameters[:model])
24
25
  add_correlation_headers(parameters, trace_id: correlation_trace_id)
26
+ add_message_redaction_header(parameters, mask_content:)
25
27
  @client.chat.completions.create(**parameters.slice(*ALLOWED_EXTRA_PARAMETERS))
26
28
  end
27
29
 
@@ -6,7 +6,12 @@ module NitroIntelligence
6
6
  module Client
7
7
  module Handlers
8
8
  module Observed
9
+ # @api private
9
10
  class AudioTranscriptionHandler
11
+ # Raised by {Client::Base#transcribe_audio} on an observed client when the prompt it names
12
+ # is not a text prompt.
13
+ #
14
+ # @api public
10
15
  class ObservedAudioTranscriptionPromptError < StandardError; end
11
16
 
12
17
  def initialize(base_handler:, observer:)
@@ -17,9 +22,8 @@ module NitroIntelligence
17
22
  def create(audio_file:, message: +"", parameters: {})
18
23
  @base_handler.validate_and_resolve!(parameters)
19
24
 
20
- # Modifies message in place
21
- prompt = handle_prompt(message:, parameters:)
22
- trace_name = parameters[:trace_name] || prompt&.name || @observer.project_client.project.slug
25
+ prompt, message = handle_prompt(message:, parameters:)
26
+ trace_name = parameters[:trace_name] || prompt&.name || project.slug
23
27
 
24
28
  @observer.observe(
25
29
  "audio-transcription",
@@ -35,12 +39,16 @@ module NitroIntelligence
35
39
 
36
40
  private
37
41
 
42
+ def project
43
+ @observer.project_client.project
44
+ end
45
+
38
46
  def handle_prompt(message:, parameters:)
39
47
  prompt = NitroIntelligence::Observability::PromptResolver.for(
40
- store: @observer.project_client.project.prompt_store,
48
+ store: project.prompt_store,
41
49
  parameters:
42
50
  )
43
- return nil if prompt.blank?
51
+ return [nil, message] if prompt.blank?
44
52
 
45
53
  # Prompts for audio transcriptions should only be text
46
54
  if prompt.type != "text"
@@ -49,25 +57,35 @@ module NitroIntelligence
49
57
  end
50
58
  interpolated_prompt = prompt.compile(**(parameters[:prompt_variables] || {}))
51
59
 
52
- message.prepend("#{interpolated_prompt} ").strip!
53
60
  parameters.merge!(prompt.config) unless parameters[:prompt_config_disabled]
54
61
 
55
- prompt
62
+ # A new string rather than the caller's, which they may send again.
63
+ [prompt, "#{interpolated_prompt} #{message}".strip]
56
64
  end
57
65
 
58
- def workflow(generation:, message:, audio_file:, parameters:)
59
- audio_transcription = @base_handler.perform_request(
60
- audio_file:, message:, parameters:, correlation_trace_id: generation.trace_id
61
- )
66
+ # Uploads go to Cerebro's media API directly, past any masking, so a masked
67
+ # project does not send the audio at all.
68
+ def upload_input_audio(audio_file, generation)
69
+ return if project.mask_content?
62
70
 
63
71
  audio_file.rewind
64
72
  upload_handler = NitroIntelligence::Observability::UploadHandler.new(
65
- auth_token: @observer.project_client.project.auth_token
73
+ auth_token: project.auth_token
66
74
  )
67
75
  upload_handler.upload(
68
76
  generation.trace_id,
77
+ observation_id: generation.id,
69
78
  upload_queue: Queue.new([NitroIntelligence::Audio.new(audio_file)])
70
79
  )
80
+ end
81
+
82
+ def workflow(generation:, message:, audio_file:, parameters:)
83
+ audio_transcription = @base_handler.perform_request(
84
+ audio_file:, message:, parameters:,
85
+ correlation_trace_id: generation.trace_id, mask_content: project.mask_content?
86
+ )
87
+
88
+ upload_input_audio(audio_file, generation)
71
89
 
72
90
  # The model and input are already recorded on the observation before the
73
91
  # request runs, and the response carries no model of its own
@@ -3,8 +3,15 @@ require "nitro_intelligence/observability/prompt_resolver"
3
3
  module NitroIntelligence
4
4
  module Client
5
5
  module Handlers
6
+ # @api private
6
7
  module Observed
8
+ # @api private
7
9
  class ChatHandler
10
+ # Raised by {Client::Base#chat} on an observed client when the request carries no user
11
+ # message for the model to answer: the prompt supplies only a system message, and the
12
+ # caller passed no `message`.
13
+ #
14
+ # @api public
8
15
  class ObservedChatPromptError < StandardError; end
9
16
 
10
17
  def initialize(base_handler:, observer:)
@@ -18,7 +25,7 @@ module NitroIntelligence
18
25
  prompt = handle_prompt(parameters:)
19
26
  validate_message_shape!(parameters:, prompt:)
20
27
 
21
- trace_name = parameters[:trace_name] || prompt&.name || @observer.project_client.project.slug
28
+ trace_name = parameters[:trace_name] || prompt&.name || project.slug
22
29
 
23
30
  @observer.observe(
24
31
  "chat-completion",
@@ -34,6 +41,10 @@ module NitroIntelligence
34
41
 
35
42
  private
36
43
 
44
+ def project
45
+ @observer.project_client.project
46
+ end
47
+
37
48
  # A chat completion needs a turn for the model to answer, and it is the model's own
38
49
  # chat template that insists on one: Qwen's raises "No user query found in
39
50
  # messages." Left to the gateway that costs a round trip and comes back as a 400
@@ -76,7 +87,7 @@ module NitroIntelligence
76
87
 
77
88
  def handle_prompt(parameters:)
78
89
  prompt = NitroIntelligence::Observability::PromptResolver.for(
79
- store: @observer.project_client.project.prompt_store,
90
+ store: project.prompt_store,
80
91
  parameters:
81
92
  )
82
93
  return nil if prompt.blank?
@@ -92,7 +103,9 @@ module NitroIntelligence
92
103
  end
93
104
 
94
105
  def workflow(generation:, parameters:)
95
- chat_completion = @base_handler.perform_request(parameters:, correlation_trace_id: generation.trace_id)
106
+ chat_completion = @base_handler.perform_request(
107
+ parameters:, correlation_trace_id: generation.trace_id, mask_content: project.mask_content?
108
+ )
96
109
  output = chat_completion.choices.first.message.to_h
97
110
 
98
111
  trace_attributes = {
@@ -0,0 +1,72 @@
1
+ module NitroIntelligence
2
+ module Client
3
+ module Handlers
4
+ module Observed
5
+ # Recorded as a generation, the closest observation type Langfuse has to a decision: it
6
+ # carries the model, usage and cost. How a decision should be recorded is still open in
7
+ # powerhome/cerebro#128.
8
+ #
9
+ # @api private
10
+ class DecisionHandler
11
+ def initialize(base_handler:, observer:)
12
+ @base_handler = base_handler
13
+ @observer = observer
14
+ end
15
+
16
+ def create(state:, questions:, parameters: {})
17
+ @base_handler.validate_and_resolve!(parameters)
18
+
19
+ trace_name = parameters[:trace_name] || project.slug
20
+
21
+ # The questions alone say nothing about why the model answered as it did, so the
22
+ # state they were asked about is recorded with them.
23
+ @observer.observe(
24
+ "decision",
25
+ type: :generation,
26
+ parameters:,
27
+ trace_name:,
28
+ input: { state:, questions: }
29
+ ) do |generation|
30
+ workflow(generation:, state:, questions:, parameters:)
31
+ end
32
+ end
33
+
34
+ private
35
+
36
+ def project
37
+ @observer.project_client.project
38
+ end
39
+
40
+ def workflow(generation:, state:, questions:, parameters:)
41
+ decision = @base_handler.perform_request(
42
+ state:, questions:, parameters:, correlation_trace_id: generation.trace_id,
43
+ mask_content: project.mask_content?
44
+ )
45
+
46
+ trace_attributes = {
47
+ model: decision.model,
48
+ # A copy, because the observer truncates the output it records in place.
49
+ output: decision.raw_answers.deep_dup,
50
+ usage_details: usage_details(decision.usage),
51
+ cost_details: @base_handler.cost_details(decision),
52
+ }
53
+
54
+ [decision, trace_attributes]
55
+ end
56
+
57
+ def usage_details(usage)
58
+ input_tokens = usage[:input_tokens]
59
+ output_tokens = usage[:output_tokens]
60
+ return nil if input_tokens.nil? && output_tokens.nil?
61
+
62
+ {
63
+ prompt_tokens: input_tokens,
64
+ completion_tokens: output_tokens,
65
+ total_tokens: (input_tokens + output_tokens if input_tokens && output_tokens),
66
+ }.compact
67
+ end
68
+ end
69
+ end
70
+ end
71
+ end
72
+ end
@@ -5,6 +5,7 @@ module NitroIntelligence
5
5
  module Client
6
6
  module Handlers
7
7
  module Observed
8
+ # @api private
8
9
  class ImageHandler
9
10
  def initialize(base_handler:, observer:)
10
11
  @base_handler = base_handler
@@ -18,7 +19,7 @@ module NitroIntelligence
18
19
 
19
20
  # Modifies parameters in place
20
21
  prompt = handle_prompt(parameters:)
21
- trace_name = parameters[:trace_name] || prompt&.name || @observer.project_client.project.slug
22
+ trace_name = parameters[:trace_name] || prompt&.name || project.slug
22
23
 
23
24
  @observer.observe(
24
25
  "image-generation",
@@ -35,6 +36,10 @@ module NitroIntelligence
35
36
 
36
37
  private
37
38
 
39
+ def project
40
+ @observer.project_client.project
41
+ end
42
+
38
43
  def build_image_generation(message:, target_image:, reference_images:, parameters:)
39
44
  NitroIntelligence::ImageGeneration.new(message:, target_image:, reference_images:) do |config|
40
45
  config.aspect_ratio = parameters[:aspect_ratio] if parameters.key?(:aspect_ratio)
@@ -44,12 +49,17 @@ module NitroIntelligence
44
49
  end
45
50
 
46
51
  def handle_image_generation_uploads(input, output, image_generation)
52
+ # Uploads go to Cerebro's media API directly, past any masking, and a masked
53
+ # project records no input or output for a media reference to sit in.
54
+ return if project.mask_content?
55
+
47
56
  # If we are doing image generation we should upload the media to observability manually
48
57
  upload_handler = NitroIntelligence::Observability::UploadHandler.new(
49
- auth_token: @observer.project_client.project.auth_token
58
+ auth_token: project.auth_token
50
59
  )
51
60
  upload_handler.upload(
52
61
  image_generation.trace_id,
62
+ observation_id: image_generation.observation_id,
53
63
  upload_queue: Queue.new(image_generation.files)
54
64
  )
55
65
 
@@ -60,7 +70,7 @@ module NitroIntelligence
60
70
 
61
71
  def handle_prompt(parameters:)
62
72
  prompt = NitroIntelligence::Observability::PromptResolver.for(
63
- store: @observer.project_client.project.prompt_store,
73
+ store: project.prompt_store,
64
74
  parameters:
65
75
  )
66
76
  return nil if prompt.blank?
@@ -76,12 +86,17 @@ module NitroIntelligence
76
86
  end
77
87
 
78
88
  def workflow(generation:, image_generation:, parameters:)
79
- chat_completion = @base_handler.perform_request(parameters:, correlation_trace_id: generation.trace_id)
89
+ chat_completion = @base_handler.perform_request(
90
+ parameters:, correlation_trace_id: generation.trace_id, mask_content: project.mask_content?
91
+ )
80
92
 
81
93
  image_generation.trace_id = generation.trace_id
94
+ image_generation.observation_id = generation.id
82
95
  image_generation.parse_file(chat_completion)
83
96
 
84
- input = parameters[:messages]
97
+ # A copy, because recording replaces its image URLs and the caller may send the same
98
+ # messages again.
99
+ input = parameters[:messages].deep_dup
85
100
  output = chat_completion.choices.first.message.to_h
86
101
  handle_image_generation_uploads(input, output, image_generation)
87
102
 
@@ -5,7 +5,12 @@ module NitroIntelligence
5
5
  module Client
6
6
  module Handlers
7
7
  module Observed
8
+ # @api private
8
9
  class TextToSpeechHandler
10
+ # Raised by {Client::Base#text_to_speech} on an observed client when the prompt it names
11
+ # is not a text prompt.
12
+ #
13
+ # @api public
9
14
  class ObservedTextToSpeechPromptError < StandardError; end
10
15
 
11
16
  def initialize(base_handler:, observer:)
@@ -18,7 +23,7 @@ module NitroIntelligence
18
23
 
19
24
  @base_handler.validate_and_resolve!(parameters)
20
25
 
21
- trace_name = parameters[:trace_name] || prompt&.name || @observer.project_client.project.slug
26
+ trace_name = parameters[:trace_name] || prompt&.name || project.slug
22
27
 
23
28
  @observer.observe(
24
29
  "text-to-speech",
@@ -28,15 +33,19 @@ module NitroIntelligence
28
33
  prompt:,
29
34
  input: message
30
35
  ) do |generation|
31
- workflow(message:, parameters:, trace_id: generation.trace_id)
36
+ workflow(generation:, message:, parameters:)
32
37
  end
33
38
  end
34
39
 
35
40
  private
36
41
 
42
+ def project
43
+ @observer.project_client.project
44
+ end
45
+
37
46
  def handle_prompt(parameters:)
38
47
  prompt = NitroIntelligence::Observability::PromptResolver.for(
39
- store: @observer.project_client.project.prompt_store,
48
+ store: project.prompt_store,
40
49
  parameters:
41
50
  )
42
51
  return nil if prompt.blank?
@@ -54,21 +63,30 @@ module NitroIntelligence
54
63
  prompt
55
64
  end
56
65
 
57
- def handle_text_to_speech_upload(tts_file, trace_id)
66
+ def handle_text_to_speech_upload(tts_file, trace_id, observation_id:)
67
+ # Uploads go to Cerebro's media API directly, past any masking. A masked
68
+ # project has no media reference to record; the observer masks the output.
69
+ return "" if project.mask_content?
70
+
58
71
  upload_handler = NitroIntelligence::Observability::UploadHandler.new(
59
- auth_token: @observer.project_client.project.auth_token
72
+ auth_token: project.auth_token
60
73
  )
74
+ audio = Audio.new(tts_file)
75
+ audio.direction = "output"
76
+
61
77
  upload_handler.upload(
62
78
  trace_id,
63
- upload_queue: Queue.new([Audio.new(tts_file)])
79
+ observation_id:,
80
+ upload_queue: Queue.new([audio])
64
81
  )
65
82
 
66
83
  uploaded_media = upload_handler.uploaded_media.first
67
84
  "@@@langfuseMedia:type=#{uploaded_media.mime_type}|id=#{uploaded_media.reference_id}|source=bytes@@@"
68
85
  end
69
86
 
70
- def workflow(message:, parameters:, trace_id:)
71
- tts = @base_handler.perform_request(message:, parameters:, correlation_trace_id: trace_id)
87
+ def workflow(generation:, message:, parameters:)
88
+ tts = @base_handler.perform_request(message:, parameters:, correlation_trace_id: generation.trace_id,
89
+ mask_content: project.mask_content?)
72
90
  output = ""
73
91
 
74
92
  Tempfile.create(["tts", ".#{parameters[:response_format]}"]) do |tempfile|
@@ -76,7 +94,7 @@ module NitroIntelligence
76
94
  tempfile.write(tts.string)
77
95
  tempfile.rewind
78
96
 
79
- output = handle_text_to_speech_upload(tempfile, trace_id)
97
+ output = handle_text_to_speech_upload(tempfile, generation.trace_id, observation_id: generation.id)
80
98
  end
81
99
 
82
100
  # Usage details and the resolved model are read off a response body, and
@@ -4,6 +4,7 @@ require "nitro_intelligence/client/handlers/base_handler"
4
4
  module NitroIntelligence
5
5
  module Client
6
6
  module Handlers
7
+ # @api private
7
8
  class TextToSpeechHandler < BaseHandler
8
9
  ALLOWED_EXTRA_PARAMETERS = OpenAI::Models::Audio::SpeechCreateParams.fields.keys.uniq.freeze
9
10
 
@@ -12,9 +13,10 @@ module NitroIntelligence
12
13
  perform_request(message:, parameters:)
13
14
  end
14
15
 
15
- def perform_request(message: "", parameters: {}, correlation_trace_id: nil)
16
+ def perform_request(message: "", parameters: {}, correlation_trace_id: nil, mask_content: false)
16
17
  add_request_headers(parameters, MODALITY_HEADER => "audio", REQUESTED_MODEL_HEADER => parameters[:model])
17
18
  add_correlation_headers(parameters, trace_id: correlation_trace_id)
19
+ add_message_redaction_header(parameters, mask_content:)
18
20
  @client.audio.speech.create(
19
21
  input: message,
20
22
  **parameters.slice(*ALLOWED_EXTRA_PARAMETERS)
@@ -1,5 +1,6 @@
1
1
  require "nitro_intelligence/client/handlers/observed/audio_transcription_handler"
2
2
  require "nitro_intelligence/client/handlers/observed/chat_handler"
3
+ require "nitro_intelligence/client/handlers/observed/decision_handler"
3
4
  require "nitro_intelligence/client/handlers/observed/image_handler"
4
5
  require "nitro_intelligence/client/handlers/observed/text_to_speech_handler"
5
6
  require "nitro_intelligence/observability/prompt_store"
@@ -8,6 +9,7 @@ require "nitro_intelligence/trace"
8
9
 
9
10
  module NitroIntelligence
10
11
  module Client
12
+ # @api private
11
13
  class Observed < Base
12
14
  def initialize(client:, observer:)
13
15
  super(client:)
@@ -29,6 +31,12 @@ module NitroIntelligence
29
31
  )
30
32
  end
31
33
 
34
+ def decision_handler
35
+ @decision_handler ||= Handlers::Observed::DecisionHandler.new(
36
+ base_handler: Handlers::DecisionHandler.new(client: @client), observer: @observer
37
+ )
38
+ end
39
+
32
40
  def image_handler
33
41
  @image_handler ||= Handlers::Observed::ImageHandler.new(
34
42
  base_handler: Handlers::ImageHandler.new(client: @client), observer: @observer