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
@@ -1,6 +1,8 @@
1
1
  module NitroIntelligence
2
2
  module Client
3
+ # @api private
3
4
  module Observers
5
+ # @api private
4
6
  class LangfuseObserver
5
7
  # The inference gateway returns its own request identifier on every
6
8
  # response, including error responses. Recording it on the observation is
@@ -25,9 +27,10 @@ module NitroIntelligence
25
27
  seed = parameters[:trace_seed]
26
28
  trace_id = NitroIntelligence::Trace.create_id(seed:) if seed.present?
27
29
 
30
+ # Into the request's own parameters, so the gateway's spend metadata names the prompt too,
31
+ # without writing into a metadata hash the caller may pass again.
28
32
  if prompt
29
- metadata[:prompt_name] = prompt.name
30
- metadata[:prompt_version] = prompt.version
33
+ metadata = parameters[:metadata] = metadata.merge(prompt_name: prompt.name, prompt_version: prompt.version)
31
34
  end
32
35
 
33
36
  metadata = metadata.transform_values(&:to_s)
@@ -55,6 +58,10 @@ module NitroIntelligence
55
58
 
56
59
  private
57
60
 
61
+ def project
62
+ @project_client.project
63
+ end
64
+
58
65
  # Applied once the response is in hand, so the observation reflects what came
59
66
  # back rather than what was asked for. Each attribute is set only when the
60
67
  # handler supplied it: an observation that records nothing is better than one
@@ -65,7 +72,12 @@ module NitroIntelligence
65
72
  def record_result(generation, trace_attributes)
66
73
  return unless trace_attributes
67
74
 
68
- handle_truncation(trace_attributes[:input], trace_attributes[:output], trace_attributes[:model])
75
+ trace_attributes = masked(trace_attributes)
76
+
77
+ # Truncation trims fields out of an output that a masked project does not record.
78
+ unless project.mask_content?
79
+ handle_truncation(trace_attributes[:input], trace_attributes[:output], trace_attributes[:model])
80
+ end
69
81
 
70
82
  RECORDED_ATTRIBUTES.each do |name|
71
83
  value = trace_attributes[name]
@@ -77,6 +89,15 @@ module NitroIntelligence
77
89
  generation.update_trace(input: trace_attributes[:input], output: trace_attributes[:output])
78
90
  end
79
91
 
92
+ def masked(trace_attributes)
93
+ return trace_attributes unless project.mask_content?
94
+
95
+ trace_attributes.merge(
96
+ input: project.mask(trace_attributes[:input]),
97
+ output: project.mask(trace_attributes[:output])
98
+ )
99
+ end
100
+
80
101
  # A response the endpoint answered but did not finish -- a generation cut off at its
81
102
  # token ceiling, say -- is neither a success worth nothing being said about nor a
82
103
  # failure the request raised on. Recording it at WARNING leaves it findable without
@@ -98,6 +119,8 @@ module NitroIntelligence
98
119
  def record_input(generation, input)
99
120
  return if input.blank?
100
121
 
122
+ input = project.mask(input)
123
+
101
124
  generation.input = input
102
125
  generation.update_trace(input:)
103
126
  end
@@ -126,15 +149,20 @@ module NitroIntelligence
126
149
  end
127
150
 
128
151
  def record_failure(generation, error)
129
- generation.update(
130
- level: "ERROR",
131
- status_message: "#{error.class}: #{error.message}".truncate(MAX_STATUS_MESSAGE_LENGTH)
132
- )
152
+ generation.update(level: "ERROR", status_message: failure_message(error))
133
153
 
134
154
  call_id = litellm_call_id(error)
135
155
  generation.metadata = { litellm_call_id: call_id } if call_id
136
156
  end
137
157
 
158
+ # The message of a gateway error can quote the request or the response, so a
159
+ # masked project records which error occurred and nothing it said.
160
+ def failure_message(error)
161
+ return error.class.to_s if project.mask_content?
162
+
163
+ "#{error.class}: #{error.message}".truncate(MAX_STATUS_MESSAGE_LENGTH)
164
+ end
165
+
138
166
  def litellm_call_id(error)
139
167
  return nil unless error.respond_to?(:headers)
140
168
 
@@ -3,24 +3,70 @@ require "logger"
3
3
  require "nitro_intelligence/null_cache"
4
4
 
5
5
  module NitroIntelligence
6
+ # The settings {NitroIntelligence.configure} yields. Each is set on the yielded object, never on
7
+ # this class.
8
+ #
9
+ # @example
10
+ # NitroIntelligence.configure do |config|
11
+ # config.logger = Rails.logger
12
+ # config.inference_api_key = "..."
13
+ # end
6
14
  class Configuration
7
15
  include ActiveSupport::Configurable
8
16
 
17
+ # @!attribute [rw] logger
18
+ # @return [Logger] where diagnostic output goes. Defaults to standard output.
9
19
  config_accessor :logger, default: Logger.new($stdout)
20
+ # @!attribute [rw] cache_provider
21
+ # @return [ActiveSupport::Cache::Store] the cache prompts are kept in, normally `Rails.cache`.
22
+ # Defaults to one that caches nothing.
10
23
  config_accessor :cache_provider, default: NitroIntelligence::NullCache.new
24
+ # @!attribute [rw] current_revision
25
+ # @return [String] the host application's revision, such as its commit, recorded on each trace
26
+ # in Cerebro as its `release`
11
27
  config_accessor :current_revision, default: ""
28
+ # @!attribute [rw] environment
29
+ # @return [String] the runtime environment, such as `Rails.env`, recorded on traces and scores.
30
+ # Defaults to `"test"`.
12
31
  config_accessor :environment, default: "test"
32
+ # @!attribute [rw] assistants_config
33
+ # @return [Hash] the assistants {NitroIntelligence.assistants} makes addressable by key: shared
34
+ # `base_url` and `user_id`, and one entry per assistant under `definitions`. Without
35
+ # `definitions` it is read as the keyword arguments of a single {Assistants} client. See the
36
+ # README's "Assistants" section.
13
37
  config_accessor :assistants_config, default: {}
38
+ # @!attribute [rw] inference_api_key
39
+ # @return [String] the API key for the inference gateway
14
40
  config_accessor :inference_api_key, default: ""
41
+ # @!attribute [rw] inference_base_url
42
+ # @return [String] the inference gateway's base URL. Defaults to the shared gateway.
15
43
  config_accessor :inference_base_url, default: "https://inference.powerhome.ai"
44
+ # @!attribute [rw] inference_max_retries
45
+ # @return [Integer] how many times the client retries a failed request itself. Defaults to 0:
46
+ # see the README's "Retries and Timeouts".
16
47
  config_accessor :inference_max_retries, default: 0
48
+ # @!attribute [rw] inference_timeout
49
+ # @return [Numeric, nil] the request timeout in seconds. Nil keeps openai-ruby's default of 600.
17
50
  config_accessor :inference_timeout, default: nil
51
+ # @!attribute [rw] model_config
52
+ # @return [Hash] the default model for each modality (`default_text_model`,
53
+ # `default_audio_transcription_model`, `default_image_model`,
54
+ # `default_text_to_speech_model`) and the per-model settings under `models`. See the README's
55
+ # "Configuration".
18
56
  config_accessor :model_config, default: {}
57
+ # @!attribute [rw] observability_base_url
58
+ # @return [String] Cerebro's base URL. Defaults to the shared deployment.
19
59
  config_accessor :observability_base_url, default: "https://cerebro.powerhome.ai"
60
+ # @!attribute [rw] observability_projects
61
+ # @return [Array<Hash>] the credentials of each Cerebro project the host reports to, each with
62
+ # `slug`, `id`, `public_key` and `secret_key`
20
63
  config_accessor :observability_projects, default: []
64
+ # @!attribute [rw] observability_user_id
65
+ # @return [String] the user recorded on observed traces when a request passes no `user_id`
21
66
  config_accessor :observability_user_id, default: ""
22
67
 
23
68
  class << self
69
+ # @api private
24
70
  def configure
25
71
  yield config
26
72
  end
@@ -0,0 +1,105 @@
1
+ module NitroIntelligence
2
+ # What {Client::Base#decide} returns: a decision model's answers to the questions asked of it,
3
+ # keyed by the names the questions were given. See https://docs.typesafe.ai/api/system-one.md
4
+ class Decision
5
+ # The answer to a `noul` question.
6
+ #
7
+ # @!attribute [r] noul
8
+ # @return [Float] the probability, from 0 to 1, that the answer is yes
9
+ NoulAnswer = Data.define(:noul)
10
+
11
+ # The answer to a `choice` question.
12
+ #
13
+ # @!attribute [r] choice
14
+ # @return [String] the most probable option's name
15
+ # @!attribute [r] confidence
16
+ # @return [Float] how concentrated the probabilities are, from 0 to 1
17
+ # @!attribute [r] probabilities
18
+ # @return [Hash{String => Float}] the probability of every option, keyed by option name
19
+ ChoiceAnswer = Data.define(:choice, :confidence, :probabilities)
20
+
21
+ # The answer to a `score` question, whose levels are numbered from 0 in the order the
22
+ # question's `criteria` listed them.
23
+ #
24
+ # @!attribute [r] score
25
+ # @return [Float] the expected level, which may fall between levels
26
+ # @!attribute [r] confidence
27
+ # @return [Float] how concentrated the probabilities are, from 0 to 1
28
+ # @!attribute [r] probabilities
29
+ # @return [Hash{Integer, String => Float}] the probability of every level, keyed by level
30
+ # number, or by the key the gateway sent where that is not a number
31
+ # @!attribute [r] legend
32
+ # @return [Hash{Integer, String => String, Hash, Array}] the description of every level, as the
33
+ # question's `criteria` gave it, keyed like `probabilities`
34
+ ScoreAnswer = Data.define(:score, :confidence, :probabilities, :legend)
35
+
36
+ # @return [String, nil] the model that answered, as the gateway named it; nil when the
37
+ # response did not name one
38
+ attr_reader :model
39
+ # @return [ActiveSupport::HashWithIndifferentAccess] the answers keyed by question name, each a
40
+ # {NoulAnswer}, {ChoiceAnswer} or {ScoreAnswer}, or, for an answer of any other type, what the
41
+ # gateway sent
42
+ attr_reader :answers
43
+ # @return [Hash] the answers as the gateway sent them, keyed by question name
44
+ attr_reader :raw_answers
45
+ # @return [ActiveSupport::HashWithIndifferentAccess] the token counts the gateway reported
46
+ attr_reader :usage
47
+ # @api private
48
+ attr_reader :response
49
+
50
+ # @!method last_response
51
+ # @return [OpenAI::ResponseMetadata, nil] the HTTP response, including the
52
+ # gateway's headers
53
+ delegate :last_response, to: :response
54
+
55
+ # @api private
56
+ def initialize(response)
57
+ @response = response
58
+ @model = response.model
59
+ @raw_answers = response[:answers].to_h
60
+ @answers = raw_answers.to_h { |name, answer| [name, build_answer(answer)] }.with_indifferent_access
61
+ @usage = response[:usage].to_h.with_indifferent_access
62
+ end
63
+
64
+ private
65
+
66
+ # A `box` answer, and any answer type added after this client, is returned as the hash it
67
+ # arrived as rather than dropped. An answer that is not a hash at all is returned as it came
68
+ # too, so it does not cost the caller every other answer in a response already paid for. Every
69
+ # answer is built from a copy, so changing one leaves `raw_answers` as the gateway sent it.
70
+ def build_answer(answer)
71
+ answer = answer.deep_dup
72
+ return answer unless answer.is_a?(Hash)
73
+
74
+ case answer[:type]
75
+ when "noul"
76
+ NoulAnswer.new(noul: answer[:noul])
77
+ when "choice"
78
+ ChoiceAnswer.new(
79
+ choice: answer[:choice],
80
+ confidence: answer[:confidence],
81
+ probabilities: answer[:probabilities].to_h.transform_keys(&:to_s)
82
+ )
83
+ when "score"
84
+ ScoreAnswer.new(
85
+ score: answer[:score],
86
+ confidence: answer[:confidence],
87
+ probabilities: levels(answer[:probabilities]),
88
+ legend: levels(answer[:legend])
89
+ )
90
+ else
91
+ answer
92
+ end
93
+ end
94
+
95
+ # Levels arrive as JSON object keys, so "0", "1" and so on. A key that is not a number is
96
+ # kept as it came rather than failing every other answer in the response.
97
+ def levels(hash)
98
+ hash.to_h.transform_keys do |level|
99
+ Integer(level.to_s, 10)
100
+ rescue ArgumentError
101
+ level.to_s
102
+ end
103
+ end
104
+ end
105
+ end
@@ -11,6 +11,7 @@
11
11
  require "nitro_intelligence/langfuse_tracer_provider"
12
12
 
13
13
  module NitroIntelligence
14
+ # @api private
14
15
  class LangfuseExtension
15
16
  attr_reader :config
16
17
 
@@ -1,10 +1,11 @@
1
- # This is an adaptation of https://github.com/simplepractice/langfuse-rb/blob/main/lib/langfuse/otel_setup.rb
2
- # that lets users create multiple Langfuse clients in the same process.
3
- # See also components/nitro_intelligence/lib/nitro_intelligence/langfuse_extension.rb
4
- #
5
- # The content of this file should eventually make its way upstream.
6
- #
7
1
  module NitroIntelligence
2
+ # This is an adaptation of https://github.com/simplepractice/langfuse-rb/blob/main/lib/langfuse/otel_setup.rb
3
+ # that lets users create multiple Langfuse clients in the same process.
4
+ # See also components/nitro_intelligence/lib/nitro_intelligence/langfuse_extension.rb
5
+ #
6
+ # The content of this file should eventually make its way upstream.
7
+ #
8
+ # @api private
8
9
  class LangfuseTracerProvider
9
10
  def initialize(config)
10
11
  @trace_id_key = OpenTelemetry::Context.create_key("nitro-intelligence-trace-id")
@@ -1,7 +1,13 @@
1
1
  require "nitro_intelligence/media/media"
2
2
 
3
3
  module NitroIntelligence
4
+ # @api private
4
5
  class Audio < Media
6
+ # Raised by {Client::Base#transcribe_audio} on an observed client whose project does not mask
7
+ # content, when `audio_file` has no path. It is raised while uploading the audio to Cerebro,
8
+ # after the gateway has transcribed it, so the transcription is lost.
9
+ #
10
+ # @api public
5
11
  class AudioFileFormatError < StandardError; end
6
12
 
7
13
  def initialize(file)
@@ -4,9 +4,21 @@ require "mini_magick"
4
4
  require "nitro_intelligence/media/media"
5
5
 
6
6
  module NitroIntelligence
7
+ # An image, such as {ImageGeneration#generated_image}.
8
+ #
9
+ # @!attribute [r] byte_string
10
+ # @return [String] the image's contents, as a byte string
11
+ # @!attribute [r] file_extension
12
+ # @return [String] the image's extension, such as `"png"`, without a dot
13
+ # @!attribute [r] mime_type
14
+ # @return [String] the image's MIME type, such as `"image/png"`
7
15
  class Image < Media
8
- attr_reader :height, :width
16
+ # @return [Integer] in pixels
17
+ attr_reader :height
18
+ # @return [Integer] in pixels
19
+ attr_reader :width
9
20
 
21
+ # @api private
10
22
  def self.from_base64(base64_string)
11
23
  # Strip data_uri from string
12
24
  base64_string = base64_string.sub(/^data:[^;]*;base64,/, "")
@@ -15,6 +27,7 @@ module NitroIntelligence
15
27
  new(byte_string)
16
28
  end
17
29
 
30
+ # @api private
18
31
  def initialize(file)
19
32
  super
20
33
 
@@ -7,7 +7,9 @@ require "time"
7
7
  require "nitro_intelligence/media/image"
8
8
 
9
9
  module NitroIntelligence
10
+ # What {Client::Base#generate_image} returns.
10
11
  class ImageGeneration
12
+ # @api private
11
13
  class Config
12
14
  DEFAULT_ASPECT_RATIO = "1:1".freeze
13
15
  DEFAULT_RESOLUTION = "1K".freeze
@@ -21,10 +23,17 @@ module NitroIntelligence
21
23
  end
22
24
  end
23
25
 
24
- attr_reader :byte_string, :config, :file_type, :file_extension, :generated_image, :messages, :reference_images,
25
- :target_image
26
+ # @api private
27
+ attr_reader :byte_string, :config, :file_type, :file_extension, :messages, :reference_images, :target_image
28
+ # @return [Image, nil] the generated image; nil when the model returned none
29
+ attr_reader :generated_image
30
+ # @return [String, nil] the trace the request was recorded under; nil without observability
26
31
  attr_accessor :trace_id
32
+ # @return [String, nil] the generation observation the images are attached to; nil without
33
+ # observability
34
+ attr_accessor :observation_id
27
35
 
36
+ # @api private
28
37
  def initialize(
29
38
  message: "",
30
39
  target_image: nil,
@@ -35,6 +44,7 @@ module NitroIntelligence
35
44
  @model = NitroIntelligence.model_catalog.lookup_by_name(@config.model)
36
45
  @reference_images = reference_images.map { |img| Image.new(img) }
37
46
  @trace_id = nil
47
+ @observation_id = nil
38
48
 
39
49
  # Overrides
40
50
  yield(@config) if block_given?
@@ -48,10 +58,12 @@ module NitroIntelligence
48
58
  build_messages(message, @target_image, @reference_images)
49
59
  end
50
60
 
61
+ # @api private
51
62
  def files
52
63
  [target_image, reference_images, generated_image].flatten.compact
53
64
  end
54
65
 
66
+ # @api private
55
67
  def parse_file(chat_completion)
56
68
  base64_string = chat_completion.choices.first&.message.to_h.fetch(:images, {})&.first&.dig(:image_url, :url)
57
69
 
@@ -1,6 +1,10 @@
1
1
  require "base64"
2
2
 
3
3
  module NitroIntelligence
4
+ # A file sent to or returned by a model. Callers see it as an {Image}, which documents the readers
5
+ # they use.
6
+ #
7
+ # @api private
4
8
  class Media
5
9
  attr_accessor :direction, :reference_id
6
10
  attr_reader :base64, :byte_string, :file_extension, :file_type, :mime_type
@@ -1,4 +1,5 @@
1
1
  module NitroIntelligence
2
+ # @api private
2
3
  class Model
3
4
  attr_reader :name, :omit_output_fields
4
5
 
@@ -8,6 +9,7 @@ module NitroIntelligence
8
9
  end
9
10
  end
10
11
 
12
+ # @api private
11
13
  class ImageModel < Model
12
14
  attr_reader :aspect_ratios, :resolutions
13
15
 
@@ -18,8 +20,13 @@ module NitroIntelligence
18
20
  end
19
21
  end
20
22
 
23
+ # @api private
24
+ class DecisionModel < Model; end
25
+
26
+ # @api private
21
27
  class TextModel < Model; end
22
28
 
29
+ # @api private
23
30
  class TextToSpeechModel < Model
24
31
  attr_reader :default_voice,
25
32
  :default_response_format,
@@ -1,9 +1,11 @@
1
1
  require "nitro_intelligence/models/model_factory"
2
2
 
3
3
  module NitroIntelligence
4
+ # @api private
4
5
  class ModelCatalog
5
6
  attr_reader :models,
6
7
  :default_audio_transcription_model,
8
+ :default_decision_model,
7
9
  :default_image_model,
8
10
  :default_text_model,
9
11
  :default_text_to_speech_model
@@ -12,6 +14,7 @@ module NitroIntelligence
12
14
  model_config = model_config.symbolize_keys
13
15
  @models = (model_config[:models] || []).map { |model_metadata| ModelFactory.build(model_metadata) }
14
16
  @default_audio_transcription_model = lookup_by_name(model_config[:default_audio_transcription_model])
17
+ @default_decision_model = lookup_by_name(model_config[:default_decision_model])
15
18
  @default_image_model = lookup_by_name(model_config[:default_image_model])
16
19
  @default_text_model = lookup_by_name(model_config[:default_text_model])
17
20
  @default_text_to_speech_model = lookup_by_name(model_config[:default_text_to_speech_model])
@@ -1,10 +1,12 @@
1
1
  require "nitro_intelligence/models/model"
2
2
 
3
3
  module NitroIntelligence
4
+ # @api private
4
5
  class ModelFactory
5
6
  TYPES = {
6
7
  "text" => TextModel,
7
8
  "audio_transcription" => TextModel,
9
+ "decision" => DecisionModel,
8
10
  "image" => ImageModel,
9
11
  "text_to_speech" => TextToSpeechModel,
10
12
  }.freeze
@@ -1,6 +1,8 @@
1
1
  module NitroIntelligence
2
2
  # Default no-operation cache implementation used
3
3
  # as a sensible default for initializing NitroIntelligence
4
+ #
5
+ # @api private
4
6
  class NullCache
5
7
  def read(_key) = nil
6
8
  def write(_key, _value, **_options) = true
@@ -2,18 +2,36 @@ require "base64"
2
2
  require "nitro_intelligence/observability/prompt_store"
3
3
 
4
4
  module NitroIntelligence
5
+ # Cerebro, the Langfuse deployment observed requests are recorded in.
5
6
  module Observability
7
+ # A Cerebro project `observability_projects` configures. Use it to reach Cerebro's API, or to
8
+ # link to the project's traces, where this gem does not.
6
9
  class Project
10
+ # Raised by {ProjectClientRegistry#fetch} when `observability_projects` does not configure the
11
+ # project.
7
12
  class NotFoundError < StandardError; end
8
13
 
9
- attr_reader :slug, :id, :public_key, :secret_key, :auth_token, :prompt_store
14
+ # @api private
15
+ # Recorded in place of anything a masked project would otherwise send to Cerebro.
16
+ MASKED_CONTENT = "[masked]".freeze
10
17
 
11
- def initialize(slug:, id:, public_key:, secret_key:, **_kwargs)
18
+ # @return [String] the project's id in Cerebro, as in its URLs:
19
+ # `"#{config.observability_base_url}/project/#{id}/traces/#{trace_id}"`
20
+ attr_reader :id
21
+ # @return [String] the Base64 `public_key:secret_key` credential for Cerebro's public API,
22
+ # without the scheme: send it as `Authorization: Basic #{auth_token}`
23
+ attr_reader :auth_token
24
+ # @api private
25
+ attr_reader :slug, :public_key, :secret_key, :prompt_store
26
+
27
+ # @api private
28
+ def initialize(slug:, id:, public_key:, secret_key:, mask_content: false, **_kwargs)
12
29
  @slug = slug
13
30
  @id = id
14
31
  @public_key = public_key
15
32
  @secret_key = secret_key
16
33
  @auth_token = Base64.strict_encode64("#{public_key}:#{secret_key}")
34
+ @mask_content = mask_content == true
17
35
  @prompt_store = PromptStore.new(
18
36
  observability_project_slug: slug,
19
37
  observability_public_key: public_key,
@@ -21,6 +39,27 @@ module NitroIntelligence
21
39
  )
22
40
  end
23
41
 
42
+ # @api private
43
+ def mask_content?
44
+ @mask_content
45
+ end
46
+
47
+ # @api private
48
+ #
49
+ # Replaces the whole value rather than fields within it: the shape of a response
50
+ # varies by provider, and redacting named fields is what let reasoning leak
51
+ # through the gateway's own redaction. Absence stays absence, so an observation
52
+ # that never had an output does not gain one.
53
+ def mask(value)
54
+ return value if !mask_content? || value.nil?
55
+
56
+ MASKED_CONTENT
57
+ end
58
+
59
+ # The project `observability_projects` configures under `slug`.
60
+ #
61
+ # @param slug [String]
62
+ # @return [Project, nil] nil when no project is configured under `slug`
24
63
  def self.find_by_slug(slug:)
25
64
  project_config = NitroIntelligence.config.observability_projects.find { |project| project["slug"] == slug }
26
65
 
@@ -1,15 +1,24 @@
1
1
  module NitroIntelligence
2
2
  module Observability
3
+ # A project's connection to Cerebro, as {ProjectClientRegistry#fetch} gives it.
3
4
  class ProjectClient
5
+ # Not raised by the gem, which raises {Project::NotFoundError} for a project
6
+ # `observability_projects` does not configure. Public only because applications raise it
7
+ # themselves.
4
8
  class NotFoundError < StandardError; end
5
9
 
6
- attr_reader :project, :observability_client
10
+ # @return [Project]
11
+ attr_reader :project
12
+ # @api private
13
+ attr_reader :observability_client
7
14
 
15
+ # @api private
8
16
  def initialize(project:, observability_client:)
9
17
  @project = project
10
18
  @observability_client = observability_client
11
19
  end
12
20
 
21
+ # @api private
13
22
  def shutdown
14
23
  @observability_client.shutdown
15
24
  end
@@ -4,16 +4,34 @@ require "nitro_intelligence/observability/project_client"
4
4
 
5
5
  module NitroIntelligence
6
6
  module Observability
7
+ # Each Cerebro project's client, built once per process, as {NitroIntelligence.project_client_registry}
8
+ # holds them. Fetching each project at boot builds its client before the first request.
9
+ #
10
+ # @example In an initializer
11
+ # NitroIntelligence.config.observability_projects.each do |project|
12
+ # NitroIntelligence.project_client_registry.fetch(project["slug"])
13
+ # end
14
+ # at_exit { NitroIntelligence.project_client_registry.shutdown_all }
7
15
  class ProjectClientRegistry
16
+ # @api private
8
17
  def initialize(base_url:)
9
18
  @base_url = base_url
10
19
  @project_clients = {}
11
20
  end
12
21
 
22
+ # The client for the project configured under `slug`, built on first use.
23
+ #
24
+ # @param slug [String]
25
+ # @return [ProjectClient]
26
+ # @raise [Project::NotFoundError] when `observability_projects` does not configure the project
13
27
  def fetch(slug)
14
28
  @project_clients[slug] ||= build_project_client(slug)
15
29
  end
16
30
 
31
+ # Sends what each client still holds to Cerebro, and stops it. Call it as the process exits, or
32
+ # the last traces and scores are lost.
33
+ #
34
+ # @return [void]
17
35
  def shutdown_all
18
36
  @project_clients.values.each(&:shutdown)
19
37
  end
@@ -1,5 +1,6 @@
1
1
  module NitroIntelligence
2
2
  module Observability
3
+ # @api private
3
4
  class Prompt
4
5
  attr_reader :name, :type, :prompt, :version, :config, :labels, :tags
5
6
 
@@ -21,9 +22,9 @@ module NitroIntelligence
21
22
  def compile(**replacements)
22
23
  return replace_variables(@prompt, **replacements) if @type == "text"
23
24
 
25
+ # New messages, so the prompt keeps its variables for the next request that compiles it.
24
26
  @prompt.map do |message|
25
- message[:content] = replace_variables(message[:content], **replacements)
26
- message
27
+ message.merge(content: replace_variables(message[:content], **replacements))
27
28
  end
28
29
  end
29
30
 
@@ -31,7 +32,7 @@ module NitroIntelligence
31
32
  # into the correct position based on prompt "type" ("text" or "chat")
32
33
  def interpolate(messages:, variables:)
33
34
  if @type == "text"
34
- messages.prepend({ role: "system", content: compile(**variables) })
35
+ [{ role: "system", content: compile(**variables) }] + messages
35
36
  elsif @type == "chat"
36
37
  compile(**variables) + messages
37
38
  end
@@ -4,6 +4,8 @@ module NitroIntelligence
4
4
  module Observability
5
5
  # Resolves a managed prompt from the lookups a request asks for, most specific first. A lookup that is
6
6
  # missing -- or that fails -- yields to the next; the last one's failure propagates.
7
+ #
8
+ # @api private
7
9
  class PromptResolver
8
10
  # One prompt lookup: the name asked for, at a label or a version.
9
11
  Lookup = Data.define(:name, :label, :version)
@@ -7,10 +7,19 @@ require "nitro_intelligence/observability/prompt"
7
7
 
8
8
  module NitroIntelligence
9
9
  module Observability
10
+ # @api private
10
11
  class PromptStore
11
12
  OBSERVABILITY_PROMPTS_CACHE_KEY_PREFIX = "nitro_intelligence_observability_prompts_".freeze
12
13
 
14
+ # Raised by an observed request when Cerebro fails to return the prompt it names. The message
15
+ # is the response body.
16
+ #
17
+ # @api public
13
18
  class ObservabilityPromptError < StandardError; end
19
+ # Raised by an observed request when the prompt it names, and its fallback if it names one,
20
+ # does not exist.
21
+ #
22
+ # @api public
14
23
  class ObservabilityPromptNotFoundError < StandardError; end
15
24
 
16
25
  def initialize(observability_project_slug:, observability_public_key:, observability_secret_key:)