nitro_intelligence 4.0.0 → 4.1.1

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 (56) 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 +247 -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 +22 -2
  12. data/lib/nitro_intelligence/client/factory.rb +1 -0
  13. data/lib/nitro_intelligence/client/handlers/audio_transcription_handler.rb +4 -4
  14. data/lib/nitro_intelligence/client/handlers/base_handler.rb +26 -2
  15. data/lib/nitro_intelligence/client/handlers/chat_handler.rb +4 -4
  16. data/lib/nitro_intelligence/client/handlers/decision_handler.rb +68 -0
  17. data/lib/nitro_intelligence/client/handlers/image_handler.rb +5 -4
  18. data/lib/nitro_intelligence/client/handlers/observed/audio_transcription_handler.rb +28 -18
  19. data/lib/nitro_intelligence/client/handlers/observed/base_handler.rb +23 -0
  20. data/lib/nitro_intelligence/client/handlers/observed/chat_handler.rb +14 -9
  21. data/lib/nitro_intelligence/client/handlers/observed/decision_handler.rb +65 -0
  22. data/lib/nitro_intelligence/client/handlers/observed/image_handler.rb +18 -11
  23. data/lib/nitro_intelligence/client/handlers/observed/text_to_speech_handler.rb +25 -15
  24. data/lib/nitro_intelligence/client/handlers/text_to_speech_handler.rb +4 -5
  25. data/lib/nitro_intelligence/client/observed.rb +8 -0
  26. data/lib/nitro_intelligence/client/observers/langfuse_observer.rb +35 -7
  27. data/lib/nitro_intelligence/configuration.rb +46 -0
  28. data/lib/nitro_intelligence/decision.rb +105 -0
  29. data/lib/nitro_intelligence/langfuse_extension.rb +1 -0
  30. data/lib/nitro_intelligence/langfuse_tracer_provider.rb +7 -6
  31. data/lib/nitro_intelligence/media/audio.rb +6 -0
  32. data/lib/nitro_intelligence/media/image.rb +14 -1
  33. data/lib/nitro_intelligence/media/image_generation.rb +15 -4
  34. data/lib/nitro_intelligence/media/media.rb +4 -0
  35. data/lib/nitro_intelligence/models/model.rb +12 -0
  36. data/lib/nitro_intelligence/models/model_catalog.rb +18 -0
  37. data/lib/nitro_intelligence/models/model_factory.rb +2 -0
  38. data/lib/nitro_intelligence/null_cache.rb +2 -0
  39. data/lib/nitro_intelligence/observability/project.rb +41 -2
  40. data/lib/nitro_intelligence/observability/project_client.rb +10 -1
  41. data/lib/nitro_intelligence/observability/project_client_registry.rb +18 -0
  42. data/lib/nitro_intelligence/observability/prompt.rb +4 -3
  43. data/lib/nitro_intelligence/observability/prompt_resolver.rb +2 -0
  44. data/lib/nitro_intelligence/observability/prompt_store.rb +9 -0
  45. data/lib/nitro_intelligence/observability/upload_handler.rb +3 -1
  46. data/lib/nitro_intelligence/pending_review.rb +36 -1
  47. data/lib/nitro_intelligence/railtie.rb +1 -0
  48. data/lib/nitro_intelligence/reporter.rb +29 -0
  49. data/lib/nitro_intelligence/review_decision.rb +2 -0
  50. data/lib/nitro_intelligence/run_result.rb +31 -0
  51. data/lib/nitro_intelligence/tool_call_review_interrupt.rb +2 -0
  52. data/lib/nitro_intelligence/tool_call_review_validator.rb +2 -0
  53. data/lib/nitro_intelligence/trace.rb +8 -0
  54. data/lib/nitro_intelligence/version.rb +2 -1
  55. data/lib/nitro_intelligence.rb +37 -0
  56. metadata +8 -1
@@ -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 AudioTranscriptionHandler < BaseHandler
8
9
  ALLOWED_EXTRA_PARAMETERS = OpenAI::Models::Audio::TranscriptionCreateParams.fields.keys.uniq.freeze
9
10
 
@@ -12,9 +13,8 @@ module NitroIntelligence
12
13
  perform_request(audio_file:, message:, parameters:)
13
14
  end
14
15
 
15
- def perform_request(audio_file:, message: "", parameters: {}, correlation_trace_id: nil)
16
- add_request_headers(parameters, MODALITY_HEADER => "audio", REQUESTED_MODEL_HEADER => parameters[:model])
17
- add_correlation_headers(parameters, trace_id: correlation_trace_id)
16
+ def perform_request(audio_file:, message: "", parameters: {}, correlation_trace_id: nil, mask_content: false)
17
+ add_gateway_headers(parameters, correlation_trace_id:, mask_content:, modality: "audio")
18
18
  @client.audio.transcriptions.create(
19
19
  prompt: message,
20
20
  file: audio_file,
@@ -29,7 +29,7 @@ module NitroIntelligence
29
29
  }
30
30
 
31
31
  parameters.replace(default_parameters.merge(parameters))
32
- Client.validate_model(parameters[:model])
32
+ Client.validate_model(parameters[:model], type: TextModel)
33
33
  end
34
34
  end
35
35
  end
@@ -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,21 @@ module NitroIntelligence
110
119
  )
111
120
  end
112
121
 
122
+ # Every request to the gateway names the model asked for, and the modality where the
123
+ # route alone does not, carries the correlation headers and, for a project that masks
124
+ # content, asks the gateway to redact it from its logs.
125
+ def add_gateway_headers(parameters, correlation_trace_id:, mask_content:, modality: nil)
126
+ add_request_headers(parameters, MODALITY_HEADER => modality, REQUESTED_MODEL_HEADER => parameters[:model])
127
+ add_correlation_headers(parameters, trace_id: correlation_trace_id)
128
+ add_message_redaction_header(parameters, mask_content:)
129
+ end
130
+
131
+ # A project that masks content in Cerebro keeps it out of the gateway's logs
132
+ # too, so one setting decides where a request's content is recorded.
133
+ def add_message_redaction_header(parameters, mask_content:)
134
+ add_request_headers(parameters, MESSAGE_REDACTION_HEADER => ("true" if mask_content))
135
+ end
136
+
113
137
  def spend_logs_metadata(metadata)
114
138
  return nil if metadata.blank?
115
139
 
@@ -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,8 @@ module NitroIntelligence
12
13
  perform_request(parameters:)
13
14
  end
14
15
 
15
- def perform_request(parameters: {}, correlation_trace_id: nil)
16
- add_request_headers(parameters, REQUESTED_MODEL_HEADER => parameters[:model])
17
- add_correlation_headers(parameters, trace_id: correlation_trace_id)
16
+ def perform_request(parameters: {}, correlation_trace_id: nil, mask_content: false)
17
+ add_gateway_headers(parameters, correlation_trace_id:, mask_content:)
18
18
  @client.chat.completions.create(**parameters.slice(*ALLOWED_EXTRA_PARAMETERS))
19
19
  end
20
20
 
@@ -31,7 +31,7 @@ module NitroIntelligence
31
31
  }
32
32
 
33
33
  parameters.replace(default_parameters.merge(parameters))
34
- Client.validate_model(parameters[:model])
34
+ Client.validate_model(parameters[:model], type: TextModel)
35
35
  end
36
36
  end
37
37
  end
@@ -0,0 +1,68 @@
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_gateway_headers(parameters, correlation_trace_id:, mask_content:)
38
+ response = @client.request(
39
+ method: :post,
40
+ path:,
41
+ body: { model: parameters[:model], state:, questions:, **parameters.slice(*BODY_PARAMETERS).compact },
42
+ model: Response,
43
+ options: parameters[:request_options]
44
+ )
45
+ Decision.new(response)
46
+ end
47
+
48
+ def validate_and_resolve!(parameters)
49
+ default_parameters = {
50
+ metadata: {},
51
+ model: NitroIntelligence.model_catalog.default_decision_model&.name,
52
+ }
53
+
54
+ parameters.replace(default_parameters.merge(parameters))
55
+ Client.validate_model(parameters[:model], type: DecisionModel)
56
+ end
57
+
58
+ private
59
+
60
+ # Both reach `/v1/systemone`: the whole route on a base URL without a version, or only the
61
+ # rest of it on one that already ends in `/v1`.
62
+ def path
63
+ @client.base_url.path.chomp("/").end_with?("/v1") ? PATH_AFTER_V1 : PATH
64
+ end
65
+ end
66
+ end
67
+ end
68
+ 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,12 +20,13 @@ module NitroIntelligence
19
20
  image_generation
20
21
  end
21
22
 
22
- def perform_request(parameters: {}, correlation_trace_id: nil)
23
- add_request_headers(parameters, MODALITY_HEADER => "image", REQUESTED_MODEL_HEADER => parameters[:model])
24
- add_correlation_headers(parameters, trace_id: correlation_trace_id)
23
+ def perform_request(parameters: {}, correlation_trace_id: nil, mask_content: false)
24
+ add_gateway_headers(parameters, correlation_trace_id:, mask_content:, modality: "image")
25
25
  @client.chat.completions.create(**parameters.slice(*ALLOWED_EXTRA_PARAMETERS))
26
26
  end
27
27
 
28
+ # The model is not checked here: building the ImageGeneration already checked it, along
29
+ # with the aspect ratio and resolution it supports.
28
30
  def validate_and_resolve!(parameters, image_generation)
29
31
  default_parameters = {
30
32
  image_generation:,
@@ -43,7 +45,6 @@ module NitroIntelligence
43
45
  # Deep, so that request options a caller passes (headers, max_retries,
44
46
  # timeout) sit alongside the image config rather than replacing it.
45
47
  parameters.replace(default_parameters.deep_merge(parameters))
46
- Client.validate_model(parameters[:model])
47
48
  end
48
49
 
49
50
  private
@@ -1,3 +1,4 @@
1
+ require "nitro_intelligence/client/handlers/observed/base_handler"
1
2
  require "base64"
2
3
  require "nitro_intelligence/media/audio"
3
4
  require "nitro_intelligence/observability/prompt_resolver"
@@ -6,20 +7,19 @@ module NitroIntelligence
6
7
  module Client
7
8
  module Handlers
8
9
  module Observed
9
- class AudioTranscriptionHandler
10
+ # @api private
11
+ class AudioTranscriptionHandler < BaseHandler
12
+ # Raised by {Client::Base#transcribe_audio} on an observed client when the prompt it names
13
+ # is not a text prompt.
14
+ #
15
+ # @api public
10
16
  class ObservedAudioTranscriptionPromptError < StandardError; end
11
17
 
12
- def initialize(base_handler:, observer:)
13
- @base_handler = base_handler
14
- @observer = observer
15
- end
16
-
17
18
  def create(audio_file:, message: +"", parameters: {})
18
19
  @base_handler.validate_and_resolve!(parameters)
19
20
 
20
- # Modifies message in place
21
- prompt = handle_prompt(message:, parameters:)
22
- trace_name = parameters[:trace_name] || prompt&.name || @observer.project_client.project.slug
21
+ prompt, message = handle_prompt(message:, parameters:)
22
+ trace_name = parameters[:trace_name] || prompt&.name || project.slug
23
23
 
24
24
  @observer.observe(
25
25
  "audio-transcription",
@@ -37,10 +37,10 @@ module NitroIntelligence
37
37
 
38
38
  def handle_prompt(message:, parameters:)
39
39
  prompt = NitroIntelligence::Observability::PromptResolver.for(
40
- store: @observer.project_client.project.prompt_store,
40
+ store: project.prompt_store,
41
41
  parameters:
42
42
  )
43
- return nil if prompt.blank?
43
+ return [nil, message] if prompt.blank?
44
44
 
45
45
  # Prompts for audio transcriptions should only be text
46
46
  if prompt.type != "text"
@@ -49,25 +49,35 @@ module NitroIntelligence
49
49
  end
50
50
  interpolated_prompt = prompt.compile(**(parameters[:prompt_variables] || {}))
51
51
 
52
- message.prepend("#{interpolated_prompt} ").strip!
53
52
  parameters.merge!(prompt.config) unless parameters[:prompt_config_disabled]
54
53
 
55
- prompt
54
+ # A new string rather than the caller's, which they may send again.
55
+ [prompt, "#{interpolated_prompt} #{message}".strip]
56
56
  end
57
57
 
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
- )
58
+ # Uploads go to Cerebro's media API directly, past any masking, so a masked
59
+ # project does not send the audio at all.
60
+ def upload_input_audio(audio_file, generation)
61
+ return if project.mask_content?
62
62
 
63
63
  audio_file.rewind
64
64
  upload_handler = NitroIntelligence::Observability::UploadHandler.new(
65
- auth_token: @observer.project_client.project.auth_token
65
+ auth_token: project.auth_token
66
66
  )
67
67
  upload_handler.upload(
68
68
  generation.trace_id,
69
+ observation_id: generation.id,
69
70
  upload_queue: Queue.new([NitroIntelligence::Audio.new(audio_file)])
70
71
  )
72
+ end
73
+
74
+ def workflow(generation:, message:, audio_file:, parameters:)
75
+ audio_transcription = @base_handler.perform_request(
76
+ audio_file:, message:, parameters:,
77
+ correlation_trace_id: generation.trace_id, mask_content: project.mask_content?
78
+ )
79
+
80
+ upload_input_audio(audio_file, generation)
71
81
 
72
82
  # The model and input are already recorded on the observation before the
73
83
  # request runs, and the response carries no model of its own
@@ -0,0 +1,23 @@
1
+ module NitroIntelligence
2
+ module Client
3
+ module Handlers
4
+ module Observed
5
+ # Wraps a handler's request in an observation recorded to the observer's Cerebro project.
6
+ #
7
+ # @api private
8
+ class BaseHandler
9
+ def initialize(base_handler:, observer:)
10
+ @base_handler = base_handler
11
+ @observer = observer
12
+ end
13
+
14
+ private
15
+
16
+ def project
17
+ @observer.project_client.project
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
@@ -1,24 +1,27 @@
1
+ require "nitro_intelligence/client/handlers/observed/base_handler"
1
2
  require "nitro_intelligence/observability/prompt_resolver"
2
3
 
3
4
  module NitroIntelligence
4
5
  module Client
5
6
  module Handlers
7
+ # @api private
6
8
  module Observed
7
- class ChatHandler
9
+ # @api private
10
+ class ChatHandler < BaseHandler
11
+ # Raised by {Client::Base#chat} on an observed client when the request carries no user
12
+ # message for the model to answer: the prompt supplies only a system message, and the
13
+ # caller passed no `message`.
14
+ #
15
+ # @api public
8
16
  class ObservedChatPromptError < StandardError; end
9
17
 
10
- def initialize(base_handler:, observer:)
11
- @base_handler = base_handler
12
- @observer = observer
13
- end
14
-
15
18
  def create(message: "", parameters: {})
16
19
  @base_handler.validate_and_resolve!(parameters, message)
17
20
 
18
21
  prompt = handle_prompt(parameters:)
19
22
  validate_message_shape!(parameters:, prompt:)
20
23
 
21
- trace_name = parameters[:trace_name] || prompt&.name || @observer.project_client.project.slug
24
+ trace_name = parameters[:trace_name] || prompt&.name || project.slug
22
25
 
23
26
  @observer.observe(
24
27
  "chat-completion",
@@ -76,7 +79,7 @@ module NitroIntelligence
76
79
 
77
80
  def handle_prompt(parameters:)
78
81
  prompt = NitroIntelligence::Observability::PromptResolver.for(
79
- store: @observer.project_client.project.prompt_store,
82
+ store: project.prompt_store,
80
83
  parameters:
81
84
  )
82
85
  return nil if prompt.blank?
@@ -92,7 +95,9 @@ module NitroIntelligence
92
95
  end
93
96
 
94
97
  def workflow(generation:, parameters:)
95
- chat_completion = @base_handler.perform_request(parameters:, correlation_trace_id: generation.trace_id)
98
+ chat_completion = @base_handler.perform_request(
99
+ parameters:, correlation_trace_id: generation.trace_id, mask_content: project.mask_content?
100
+ )
96
101
  output = chat_completion.choices.first.message.to_h
97
102
 
98
103
  trace_attributes = {
@@ -0,0 +1,65 @@
1
+ require "nitro_intelligence/client/handlers/observed/base_handler"
2
+
3
+ module NitroIntelligence
4
+ module Client
5
+ module Handlers
6
+ module Observed
7
+ # Recorded as a generation, the closest observation type Langfuse has to a decision: it
8
+ # carries the model, usage and cost. How a decision should be recorded is still open in
9
+ # powerhome/cerebro#128.
10
+ #
11
+ # @api private
12
+ class DecisionHandler < BaseHandler
13
+ def create(state:, questions:, parameters: {})
14
+ @base_handler.validate_and_resolve!(parameters)
15
+
16
+ trace_name = parameters[:trace_name] || project.slug
17
+
18
+ # The questions alone say nothing about why the model answered as it did, so the
19
+ # state they were asked about is recorded with them.
20
+ @observer.observe(
21
+ "decision",
22
+ type: :generation,
23
+ parameters:,
24
+ trace_name:,
25
+ input: { state:, questions: }
26
+ ) do |generation|
27
+ workflow(generation:, state:, questions:, parameters:)
28
+ end
29
+ end
30
+
31
+ private
32
+
33
+ def workflow(generation:, state:, questions:, parameters:)
34
+ decision = @base_handler.perform_request(
35
+ state:, questions:, parameters:, correlation_trace_id: generation.trace_id,
36
+ mask_content: project.mask_content?
37
+ )
38
+
39
+ trace_attributes = {
40
+ model: decision.model,
41
+ # A copy, because the observer truncates the output it records in place.
42
+ output: decision.raw_answers.deep_dup,
43
+ usage_details: usage_details(decision.usage),
44
+ cost_details: @base_handler.cost_details(decision),
45
+ }
46
+
47
+ [decision, trace_attributes]
48
+ end
49
+
50
+ def usage_details(usage)
51
+ input_tokens = usage[:input_tokens]
52
+ output_tokens = usage[:output_tokens]
53
+ return nil if input_tokens.nil? && output_tokens.nil?
54
+
55
+ {
56
+ prompt_tokens: input_tokens,
57
+ completion_tokens: output_tokens,
58
+ total_tokens: (input_tokens + output_tokens if input_tokens && output_tokens),
59
+ }.compact
60
+ end
61
+ end
62
+ end
63
+ end
64
+ end
65
+ end
@@ -1,3 +1,4 @@
1
+ require "nitro_intelligence/client/handlers/observed/base_handler"
1
2
  require "nitro_intelligence/media/image_generation"
2
3
  require "nitro_intelligence/observability/prompt_resolver"
3
4
 
@@ -5,12 +6,8 @@ module NitroIntelligence
5
6
  module Client
6
7
  module Handlers
7
8
  module Observed
8
- class ImageHandler
9
- def initialize(base_handler:, observer:)
10
- @base_handler = base_handler
11
- @observer = observer
12
- end
13
-
9
+ # @api private
10
+ class ImageHandler < BaseHandler
14
11
  def create(message: "", target_image: nil, reference_images: [], parameters: {})
15
12
  image_generation = build_image_generation(message:, target_image:, reference_images:, parameters:)
16
13
 
@@ -18,7 +15,7 @@ module NitroIntelligence
18
15
 
19
16
  # Modifies parameters in place
20
17
  prompt = handle_prompt(parameters:)
21
- trace_name = parameters[:trace_name] || prompt&.name || @observer.project_client.project.slug
18
+ trace_name = parameters[:trace_name] || prompt&.name || project.slug
22
19
 
23
20
  @observer.observe(
24
21
  "image-generation",
@@ -44,12 +41,17 @@ module NitroIntelligence
44
41
  end
45
42
 
46
43
  def handle_image_generation_uploads(input, output, image_generation)
44
+ # Uploads go to Cerebro's media API directly, past any masking, and a masked
45
+ # project records no input or output for a media reference to sit in.
46
+ return if project.mask_content?
47
+
47
48
  # If we are doing image generation we should upload the media to observability manually
48
49
  upload_handler = NitroIntelligence::Observability::UploadHandler.new(
49
- auth_token: @observer.project_client.project.auth_token
50
+ auth_token: project.auth_token
50
51
  )
51
52
  upload_handler.upload(
52
53
  image_generation.trace_id,
54
+ observation_id: image_generation.observation_id,
53
55
  upload_queue: Queue.new(image_generation.files)
54
56
  )
55
57
 
@@ -60,7 +62,7 @@ module NitroIntelligence
60
62
 
61
63
  def handle_prompt(parameters:)
62
64
  prompt = NitroIntelligence::Observability::PromptResolver.for(
63
- store: @observer.project_client.project.prompt_store,
65
+ store: project.prompt_store,
64
66
  parameters:
65
67
  )
66
68
  return nil if prompt.blank?
@@ -76,12 +78,17 @@ module NitroIntelligence
76
78
  end
77
79
 
78
80
  def workflow(generation:, image_generation:, parameters:)
79
- chat_completion = @base_handler.perform_request(parameters:, correlation_trace_id: generation.trace_id)
81
+ chat_completion = @base_handler.perform_request(
82
+ parameters:, correlation_trace_id: generation.trace_id, mask_content: project.mask_content?
83
+ )
80
84
 
81
85
  image_generation.trace_id = generation.trace_id
86
+ image_generation.observation_id = generation.id
82
87
  image_generation.parse_file(chat_completion)
83
88
 
84
- input = parameters[:messages]
89
+ # A copy, because recording replaces its image URLs and the caller may send the same
90
+ # messages again.
91
+ input = parameters[:messages].deep_dup
85
92
  output = chat_completion.choices.first.message.to_h
86
93
  handle_image_generation_uploads(input, output, image_generation)
87
94
 
@@ -1,3 +1,4 @@
1
+ require "nitro_intelligence/client/handlers/observed/base_handler"
1
2
  require "nitro_intelligence/media/audio"
2
3
  require "nitro_intelligence/observability/prompt_resolver"
3
4
 
@@ -5,20 +6,20 @@ module NitroIntelligence
5
6
  module Client
6
7
  module Handlers
7
8
  module Observed
8
- class TextToSpeechHandler
9
+ # @api private
10
+ class TextToSpeechHandler < BaseHandler
11
+ # Raised by {Client::Base#text_to_speech} on an observed client when the prompt it names
12
+ # is not a text prompt.
13
+ #
14
+ # @api public
9
15
  class ObservedTextToSpeechPromptError < StandardError; end
10
16
 
11
- def initialize(base_handler:, observer:)
12
- @base_handler = base_handler
13
- @observer = observer
14
- end
15
-
16
17
  def create(message: "", parameters: {})
17
18
  prompt = handle_prompt(parameters:)
18
19
 
19
20
  @base_handler.validate_and_resolve!(parameters)
20
21
 
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
  "text-to-speech",
@@ -28,7 +29,7 @@ module NitroIntelligence
28
29
  prompt:,
29
30
  input: message
30
31
  ) do |generation|
31
- workflow(message:, parameters:, trace_id: generation.trace_id)
32
+ workflow(generation:, message:, parameters:)
32
33
  end
33
34
  end
34
35
 
@@ -36,7 +37,7 @@ module NitroIntelligence
36
37
 
37
38
  def handle_prompt(parameters:)
38
39
  prompt = NitroIntelligence::Observability::PromptResolver.for(
39
- store: @observer.project_client.project.prompt_store,
40
+ store: project.prompt_store,
40
41
  parameters:
41
42
  )
42
43
  return nil if prompt.blank?
@@ -54,21 +55,30 @@ module NitroIntelligence
54
55
  prompt
55
56
  end
56
57
 
57
- def handle_text_to_speech_upload(tts_file, trace_id)
58
+ def handle_text_to_speech_upload(tts_file, trace_id, observation_id:)
59
+ # Uploads go to Cerebro's media API directly, past any masking. A masked
60
+ # project has no media reference to record; the observer masks the output.
61
+ return "" if project.mask_content?
62
+
58
63
  upload_handler = NitroIntelligence::Observability::UploadHandler.new(
59
- auth_token: @observer.project_client.project.auth_token
64
+ auth_token: project.auth_token
60
65
  )
66
+ audio = Audio.new(tts_file)
67
+ audio.direction = "output"
68
+
61
69
  upload_handler.upload(
62
70
  trace_id,
63
- upload_queue: Queue.new([Audio.new(tts_file)])
71
+ observation_id:,
72
+ upload_queue: Queue.new([audio])
64
73
  )
65
74
 
66
75
  uploaded_media = upload_handler.uploaded_media.first
67
76
  "@@@langfuseMedia:type=#{uploaded_media.mime_type}|id=#{uploaded_media.reference_id}|source=bytes@@@"
68
77
  end
69
78
 
70
- def workflow(message:, parameters:, trace_id:)
71
- tts = @base_handler.perform_request(message:, parameters:, correlation_trace_id: trace_id)
79
+ def workflow(generation:, message:, parameters:)
80
+ tts = @base_handler.perform_request(message:, parameters:, correlation_trace_id: generation.trace_id,
81
+ mask_content: project.mask_content?)
72
82
  output = ""
73
83
 
74
84
  Tempfile.create(["tts", ".#{parameters[:response_format]}"]) do |tempfile|
@@ -76,7 +86,7 @@ module NitroIntelligence
76
86
  tempfile.write(tts.string)
77
87
  tempfile.rewind
78
88
 
79
- output = handle_text_to_speech_upload(tempfile, trace_id)
89
+ output = handle_text_to_speech_upload(tempfile, generation.trace_id, observation_id: generation.id)
80
90
  end
81
91
 
82
92
  # 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,8 @@ module NitroIntelligence
12
13
  perform_request(message:, parameters:)
13
14
  end
14
15
 
15
- def perform_request(message: "", parameters: {}, correlation_trace_id: nil)
16
- add_request_headers(parameters, MODALITY_HEADER => "audio", REQUESTED_MODEL_HEADER => parameters[:model])
17
- add_correlation_headers(parameters, trace_id: correlation_trace_id)
16
+ def perform_request(message: "", parameters: {}, correlation_trace_id: nil, mask_content: false)
17
+ add_gateway_headers(parameters, correlation_trace_id:, mask_content:, modality: "audio")
18
18
  @client.audio.speech.create(
19
19
  input: message,
20
20
  **parameters.slice(*ALLOWED_EXTRA_PARAMETERS)
@@ -23,8 +23,7 @@ module NitroIntelligence
23
23
 
24
24
  def validate_and_resolve!(parameters)
25
25
  model_name = parameters[:model] || NitroIntelligence.model_catalog.default_text_to_speech_model&.name
26
- model = NitroIntelligence.model_catalog.lookup_by_name(model_name)
27
- raise ArgumentError, "Unsupported model: '#{model_name}'" unless model
26
+ model = Client.validate_model(model_name, type: TextToSpeechModel)
28
27
 
29
28
  default_parameters = {
30
29
  metadata: {},