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
@@ -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
@@ -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
 
@@ -111,8 +123,7 @@ module NitroIntelligence
111
123
 
112
124
  def validate_config!
113
125
  # Check model supported
114
- @model = NitroIntelligence.model_catalog.lookup_by_name(@config.model)
115
- raise ArgumentError, "Unsupported model: '#{@config.model}'" unless @model
126
+ @model = NitroIntelligence.model_catalog.fetch(@config.model, type: ImageModel)
116
127
 
117
128
  # Check aspect_ratio supported
118
129
  unless @model.aspect_ratios.include?(@config.aspect_ratio)
@@ -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,5 +1,11 @@
1
1
  module NitroIntelligence
2
+ # @api private
2
3
  class Model
4
+ # What the model is for, as an error names it: "image" for ImageModel, "text-to-speech" for
5
+ # TextToSpeechModel. Taken from the class name, so a new kind of model has one without
6
+ # declaring it.
7
+ def self.kind = name.demodulize.delete_suffix("Model").underscore.dasherize
8
+
3
9
  attr_reader :name, :omit_output_fields
4
10
 
5
11
  def initialize(name:, omit_output_fields: [], **)
@@ -8,6 +14,7 @@ module NitroIntelligence
8
14
  end
9
15
  end
10
16
 
17
+ # @api private
11
18
  class ImageModel < Model
12
19
  attr_reader :aspect_ratios, :resolutions
13
20
 
@@ -18,8 +25,13 @@ module NitroIntelligence
18
25
  end
19
26
  end
20
27
 
28
+ # @api private
29
+ class DecisionModel < Model; end
30
+
31
+ # @api private
21
32
  class TextModel < Model; end
22
33
 
34
+ # @api private
23
35
  class TextToSpeechModel < Model
24
36
  attr_reader :default_voice,
25
37
  :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])
@@ -21,6 +24,21 @@ module NitroIntelligence
21
24
  @models.find { |model| model.name == name }
22
25
  end
23
26
 
27
+ # The named model, checked to be of the type a request needs. A model of another type
28
+ # would fail at the gateway, or deep in a handler's own validation, with an error that
29
+ # does not say why.
30
+ def fetch(name, type: Model)
31
+ model = lookup_by_name(name)
32
+ raise ArgumentError, "Unsupported model: '#{name}'" unless model
33
+
34
+ return model if model.is_a?(type)
35
+
36
+ article = type.kind.start_with?(/[aeiou]/) ? "an" : "a"
37
+ raise ArgumentError, "Model '#{name}' is not #{article} #{type.kind} model"
38
+ end
39
+
40
+ # Unused within the gem, which validates through #fetch, but kept for applications that check
41
+ # whether a model is configured before offering it.
24
42
  def exists?(name)
25
43
  lookup_by_name(name).present?
26
44
  end
@@ -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