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.
- checksums.yaml +4 -4
- data/.yardopts +9 -0
- data/Rakefile +21 -1
- data/docs/ASSISTANTS.md +594 -0
- data/docs/CHANGELOG.md +243 -0
- data/docs/README.md +140 -4
- data/lib/nitro_intelligence/assistant.rb +70 -1
- data/lib/nitro_intelligence/assistant_registry.rb +18 -0
- data/lib/nitro_intelligence/assistants.rb +146 -24
- data/lib/nitro_intelligence/client/base.rb +88 -8
- data/lib/nitro_intelligence/client/client.rb +20 -0
- data/lib/nitro_intelligence/client/factory.rb +1 -0
- data/lib/nitro_intelligence/client/handlers/audio_transcription_handler.rb +3 -1
- data/lib/nitro_intelligence/client/handlers/base_handler.rb +17 -2
- data/lib/nitro_intelligence/client/handlers/chat_handler.rb +3 -1
- data/lib/nitro_intelligence/client/handlers/decision_handler.rb +75 -0
- data/lib/nitro_intelligence/client/handlers/image_handler.rb +3 -1
- data/lib/nitro_intelligence/client/handlers/observed/audio_transcription_handler.rb +30 -12
- data/lib/nitro_intelligence/client/handlers/observed/chat_handler.rb +16 -3
- data/lib/nitro_intelligence/client/handlers/observed/decision_handler.rb +72 -0
- data/lib/nitro_intelligence/client/handlers/observed/image_handler.rb +20 -5
- data/lib/nitro_intelligence/client/handlers/observed/text_to_speech_handler.rb +27 -9
- data/lib/nitro_intelligence/client/handlers/text_to_speech_handler.rb +3 -1
- data/lib/nitro_intelligence/client/observed.rb +8 -0
- data/lib/nitro_intelligence/client/observers/langfuse_observer.rb +35 -7
- data/lib/nitro_intelligence/configuration.rb +46 -0
- data/lib/nitro_intelligence/decision.rb +105 -0
- data/lib/nitro_intelligence/langfuse_extension.rb +1 -0
- data/lib/nitro_intelligence/langfuse_tracer_provider.rb +7 -6
- data/lib/nitro_intelligence/media/audio.rb +6 -0
- data/lib/nitro_intelligence/media/image.rb +14 -1
- data/lib/nitro_intelligence/media/image_generation.rb +14 -2
- data/lib/nitro_intelligence/media/media.rb +4 -0
- data/lib/nitro_intelligence/models/model.rb +7 -0
- data/lib/nitro_intelligence/models/model_catalog.rb +3 -0
- data/lib/nitro_intelligence/models/model_factory.rb +2 -0
- data/lib/nitro_intelligence/null_cache.rb +2 -0
- data/lib/nitro_intelligence/observability/project.rb +41 -2
- data/lib/nitro_intelligence/observability/project_client.rb +10 -1
- data/lib/nitro_intelligence/observability/project_client_registry.rb +18 -0
- data/lib/nitro_intelligence/observability/prompt.rb +4 -3
- data/lib/nitro_intelligence/observability/prompt_resolver.rb +2 -0
- data/lib/nitro_intelligence/observability/prompt_store.rb +9 -0
- data/lib/nitro_intelligence/observability/upload_handler.rb +3 -1
- data/lib/nitro_intelligence/pending_review.rb +36 -1
- data/lib/nitro_intelligence/railtie.rb +1 -0
- data/lib/nitro_intelligence/reporter.rb +29 -0
- data/lib/nitro_intelligence/review_decision.rb +2 -0
- data/lib/nitro_intelligence/run_result.rb +31 -0
- data/lib/nitro_intelligence/tool_call_review_interrupt.rb +2 -0
- data/lib/nitro_intelligence/tool_call_review_validator.rb +2 -0
- data/lib/nitro_intelligence/trace.rb +8 -0
- data/lib/nitro_intelligence/version.rb +2 -1
- data/lib/nitro_intelligence.rb +37 -0
- 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[:
|
|
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
|
-
|
|
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
|
|
@@ -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
|
-
|
|
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
|
-
|
|
25
|
-
|
|
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
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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:)
|