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