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