nitro_intelligence 3.1.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 +190 -12
- data/lib/nitro_intelligence/assistant.rb +82 -3
- data/lib/nitro_intelligence/assistant_registry.rb +18 -0
- data/lib/nitro_intelligence/assistants.rb +340 -102
- 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 +12 -3
- 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 +6 -2
- 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 +48 -0
- data/lib/nitro_intelligence/decision.rb +105 -0
- data/lib/nitro_intelligence/deprecator.rb +10 -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 +257 -0
- data/lib/nitro_intelligence/railtie.rb +10 -0
- data/lib/nitro_intelligence/reporter.rb +29 -0
- data/lib/nitro_intelligence/review_decision.rb +36 -0
- data/lib/nitro_intelligence/run_result.rb +160 -0
- data/lib/nitro_intelligence/tool_call_review_interrupt.rb +9 -13
- data/lib/nitro_intelligence/tool_call_review_validator.rb +6 -8
- data/lib/nitro_intelligence/trace.rb +8 -0
- data/lib/nitro_intelligence/version.rb +2 -1
- data/lib/nitro_intelligence.rb +40 -0
- metadata +12 -1
|
@@ -1,35 +1,111 @@
|
|
|
1
1
|
require "nitro_intelligence/client/handlers/audio_transcription_handler"
|
|
2
2
|
require "nitro_intelligence/client/handlers/chat_handler"
|
|
3
|
+
require "nitro_intelligence/client/handlers/decision_handler"
|
|
3
4
|
require "nitro_intelligence/client/handlers/image_handler"
|
|
4
5
|
require "nitro_intelligence/client/handlers/text_to_speech_handler"
|
|
5
6
|
|
|
6
7
|
module NitroIntelligence
|
|
7
8
|
module Client
|
|
9
|
+
# A client {Client.new} builds. Every method takes `parameters`, the request's openai-ruby
|
|
10
|
+
# parameters plus this gem's own (`prompt_name`, `prompt_variables`, `trace_seed`, `metadata`,
|
|
11
|
+
# `user_id` and the rest the README describes), and defaults the model to the configured
|
|
12
|
+
# default for its modality.
|
|
13
|
+
#
|
|
14
|
+
# Each request works on a copy of the caller's `parameters`, which the handlers fill in with
|
|
15
|
+
# defaults and gateway headers, so a hash passed to several requests carries nothing from one
|
|
16
|
+
# request into the next.
|
|
8
17
|
class Base
|
|
18
|
+
# @api private
|
|
9
19
|
attr_reader :client
|
|
10
20
|
|
|
21
|
+
# @api private
|
|
11
22
|
def initialize(client:)
|
|
12
23
|
@client = client
|
|
13
24
|
end
|
|
14
25
|
|
|
26
|
+
# Creates a chat completion.
|
|
27
|
+
#
|
|
28
|
+
# @param message [String] the user's message, used when `parameters` carries no `messages`
|
|
29
|
+
# @param parameters [Hash] openai-ruby's chat completion parameters, and this gem's own
|
|
30
|
+
# @return [OpenAI::Models::Chat::ChatCompletion]
|
|
31
|
+
# @raise [ArgumentError] when the model is not one `model_config` lists
|
|
32
|
+
# @raise [Handlers::Observed::ChatHandler::ObservedChatPromptError] on an observed client, when
|
|
33
|
+
# the request carries no user message for the model to answer
|
|
34
|
+
# @example
|
|
35
|
+
# client.chat(message: "Why is the sky blue?").choices.first&.message&.content
|
|
15
36
|
def chat(message: "", parameters: {})
|
|
16
|
-
chat_handler.create(message:, parameters:)
|
|
37
|
+
chat_handler.create(message:, parameters: parameters.dup)
|
|
17
38
|
end
|
|
18
39
|
|
|
19
|
-
#
|
|
40
|
+
# Asks a decision model questions about some content over the gateway's System One route,
|
|
41
|
+
# `POST /v1/systemone`.
|
|
42
|
+
#
|
|
43
|
+
# @param state [String, Hash, Array] the content the questions are about
|
|
44
|
+
# @param questions [Hash] each question's name mapped to its `type` (`noul`, `choice` or
|
|
45
|
+
# `score`), `instructions` and, for `choice` and `score`, `criteria`: the options keyed by
|
|
46
|
+
# name, or the levels in order
|
|
47
|
+
# @param parameters [Hash] `model`, `assets` (images `state` and the questions refer to),
|
|
48
|
+
# `metadata` and `request_options`, and on an observed client `trace_name`, `trace_seed`,
|
|
49
|
+
# `user_id`, `session_id` and `tags`. It takes no managed prompt: `prompt_name` and
|
|
50
|
+
# `prompt_variables` are ignored, so the instructions belong in `questions`.
|
|
51
|
+
# @return [Decision]
|
|
52
|
+
# @raise [ArgumentError] when the model is not one `model_config` lists with the type
|
|
53
|
+
# `decision`
|
|
54
|
+
# @example
|
|
55
|
+
# client.decide(
|
|
56
|
+
# state: "I was charged twice.",
|
|
57
|
+
# questions: { billing: { type: "noul", instructions: "Is this message about billing?" } }
|
|
58
|
+
# ).answers[:billing].noul
|
|
59
|
+
def decide(state:, questions:, parameters: {})
|
|
60
|
+
decision_handler.create(state:, questions:, parameters: parameters.dup)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Generates an image, or edits `target_image`.
|
|
64
|
+
#
|
|
65
|
+
# @param message [String] what to generate, or how to edit the target
|
|
66
|
+
# @param target_image [String, nil] the image to edit, as a byte string
|
|
67
|
+
# @param reference_images [Array<String>] further images to draw on, as byte strings
|
|
68
|
+
# @param parameters [Hash] `model`, `aspect_ratio` and `resolution`, among those `model_config`
|
|
69
|
+
# lists for the model, and this gem's own
|
|
70
|
+
# @return [ImageGeneration]
|
|
71
|
+
# @raise [ArgumentError] when the model, aspect ratio or resolution is not one `model_config`
|
|
72
|
+
# lists
|
|
73
|
+
# @example
|
|
74
|
+
# result = client.generate_image(message: "A bear installing a window")
|
|
75
|
+
# File.binwrite("bear.#{result.generated_image.file_extension}", result.generated_image.byte_string)
|
|
20
76
|
def generate_image(message: "", target_image: nil, reference_images: [], parameters: {})
|
|
21
|
-
image_handler.create(message:, target_image:, reference_images:, parameters:)
|
|
77
|
+
image_handler.create(message:, target_image:, reference_images:, parameters: parameters.dup)
|
|
22
78
|
end
|
|
23
79
|
|
|
24
|
-
#
|
|
25
|
-
#
|
|
80
|
+
# Transcribes audio into text.
|
|
81
|
+
#
|
|
82
|
+
# @param message [String] a prompt to guide the transcription, such as its vocabulary or style
|
|
83
|
+
# @param audio_file [File] the audio, opened in binary mode, with a filename whose extension
|
|
84
|
+
# gives its format
|
|
85
|
+
# @param parameters [Hash] openai-ruby's transcription parameters, and this gem's own
|
|
86
|
+
# @return [OpenAI::Models::Audio::Transcription, OpenAI::Models::Audio::TranscriptionVerbose,
|
|
87
|
+
# OpenAI::Models::Audio::TranscriptionDiarized, StringIO] whichever openai-ruby returns for
|
|
88
|
+
# the `response_format`: a `Transcription` for `json`, the default, and the response body as a
|
|
89
|
+
# StringIO for `text`, `srt` and `vtt`. An observed client records the transcription's `text`,
|
|
90
|
+
# so it supports only the formats that return an object.
|
|
91
|
+
# @raise [ArgumentError] when the model is not one `model_config` lists
|
|
92
|
+
# @example
|
|
93
|
+
# client.transcribe_audio(audio_file: File.open("sample.m4a", "rb")).text
|
|
26
94
|
def transcribe_audio(message: +"", audio_file: nil, parameters: {})
|
|
27
|
-
audio_transcription_handler.create(message:, audio_file:, parameters:)
|
|
95
|
+
audio_transcription_handler.create(message:, audio_file:, parameters: parameters.dup)
|
|
28
96
|
end
|
|
29
97
|
|
|
30
|
-
#
|
|
98
|
+
# Speaks text aloud.
|
|
99
|
+
#
|
|
100
|
+
# @param message [String] the text to speak
|
|
101
|
+
# @param parameters [Hash] openai-ruby's speech parameters, with `voice` and `response_format`
|
|
102
|
+
# among those `model_config` lists for the model, and this gem's own
|
|
103
|
+
# @return [StringIO] the audio
|
|
104
|
+
# @raise [ArgumentError] when the model, voice or format is not one `model_config` lists
|
|
105
|
+
# @example
|
|
106
|
+
# File.binwrite("hello.mp3", client.text_to_speech(message: "Hello").string)
|
|
31
107
|
def text_to_speech(message: "", parameters: {})
|
|
32
|
-
text_to_speech_handler.create(message:, parameters:)
|
|
108
|
+
text_to_speech_handler.create(message:, parameters: parameters.dup)
|
|
33
109
|
end
|
|
34
110
|
|
|
35
111
|
private
|
|
@@ -42,6 +118,10 @@ module NitroIntelligence
|
|
|
42
118
|
@chat_handler ||= Handlers::ChatHandler.new(client: @client)
|
|
43
119
|
end
|
|
44
120
|
|
|
121
|
+
def decision_handler
|
|
122
|
+
@decision_handler ||= Handlers::DecisionHandler.new(client: @client)
|
|
123
|
+
end
|
|
124
|
+
|
|
45
125
|
def image_handler
|
|
46
126
|
@image_handler ||= Handlers::ImageHandler.new(client: @client)
|
|
47
127
|
end
|
|
@@ -1,11 +1,31 @@
|
|
|
1
1
|
require "nitro_intelligence/client/factory"
|
|
2
2
|
|
|
3
3
|
module NitroIntelligence
|
|
4
|
+
# Inference through the inference gateway: chat, image generation and editing, audio
|
|
5
|
+
# transcription and text-to-speech. See the README's "Basic Usage".
|
|
4
6
|
module Client
|
|
5
|
-
|
|
6
|
-
|
|
7
|
+
# Builds a client, which records every request in Cerebro when given a project to report to.
|
|
8
|
+
#
|
|
9
|
+
# A project slug that `observability_projects` does not configure logs a warning and gives a
|
|
10
|
+
# client without observability rather than raising. Building a client makes no request to
|
|
11
|
+
# Cerebro, so a configured project with wrong credentials still gives an observed client, and the
|
|
12
|
+
# error surfaces on the first request that reaches Cerebro, such as a prompt lookup.
|
|
13
|
+
#
|
|
14
|
+
# @param observability_project_slug [String, nil] the Cerebro project to record requests in, and
|
|
15
|
+
# to fetch prompts from
|
|
16
|
+
# @param inference_max_retries [Integer, nil] overrides the configured `inference_max_retries`
|
|
17
|
+
# for this client
|
|
18
|
+
# @param inference_timeout [Numeric, nil] overrides the configured `inference_timeout` for this
|
|
19
|
+
# client, in seconds
|
|
20
|
+
# @return [Client::Base]
|
|
21
|
+
# @example
|
|
22
|
+
# client = NitroIntelligence::Client.new(observability_project_slug: "my-feature-project")
|
|
23
|
+
# client.chat(message: "Why is the sky blue?")
|
|
24
|
+
def self.new(observability_project_slug: nil, inference_max_retries: nil, inference_timeout: nil)
|
|
25
|
+
Factory.new(observability_project_slug:, inference_max_retries:, inference_timeout:).build
|
|
7
26
|
end
|
|
8
27
|
|
|
28
|
+
# @api private
|
|
9
29
|
def self.validate_model(model)
|
|
10
30
|
raise ArgumentError, "Unsupported model: '#{model}'" unless NitroIntelligence.model_catalog.exists?(model)
|
|
11
31
|
end
|
|
@@ -5,9 +5,12 @@ require "nitro_intelligence/observability/project"
|
|
|
5
5
|
|
|
6
6
|
module NitroIntelligence
|
|
7
7
|
module Client
|
|
8
|
+
# @api private
|
|
8
9
|
class Factory
|
|
9
|
-
def initialize(observability_project_slug:)
|
|
10
|
+
def initialize(observability_project_slug:, inference_max_retries: nil, inference_timeout: nil)
|
|
10
11
|
@observability_project_slug = observability_project_slug
|
|
12
|
+
@inference_max_retries = inference_max_retries
|
|
13
|
+
@inference_timeout = inference_timeout
|
|
11
14
|
end
|
|
12
15
|
|
|
13
16
|
def build
|
|
@@ -42,10 +45,16 @@ module NitroIntelligence
|
|
|
42
45
|
project_client
|
|
43
46
|
end
|
|
44
47
|
|
|
48
|
+
# The gateway owns failover between providers, and an upstream 429 it passes
|
|
49
|
+
# on has already been decided there, so the SDK's own retries default to off
|
|
50
|
+
# rather than to openai-ruby's two. A nil timeout keeps openai-ruby's default.
|
|
45
51
|
def inference_client
|
|
52
|
+
config = NitroIntelligence.config
|
|
46
53
|
@inference_client ||= OpenAI::Client.new(
|
|
47
|
-
api_key:
|
|
48
|
-
base_url:
|
|
54
|
+
api_key: config.inference_api_key,
|
|
55
|
+
base_url: config.inference_base_url,
|
|
56
|
+
max_retries: @inference_max_retries.nil? ? config.inference_max_retries : @inference_max_retries,
|
|
57
|
+
**{ timeout: @inference_timeout || config.inference_timeout }.compact
|
|
49
58
|
)
|
|
50
59
|
end
|
|
51
60
|
end
|
|
@@ -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,10 @@ 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
|
+
def perform_request(audio_file:, 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.transcriptions.create(
|
|
19
21
|
prompt: message,
|
|
20
22
|
file: audio_file,
|
|
@@ -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
|
|
|
@@ -40,7 +42,9 @@ module NitroIntelligence
|
|
|
40
42
|
},
|
|
41
43
|
},
|
|
42
44
|
}
|
|
43
|
-
|
|
45
|
+
# Deep, so that request options a caller passes (headers, max_retries,
|
|
46
|
+
# timeout) sit alongside the image config rather than replacing it.
|
|
47
|
+
parameters.replace(default_parameters.deep_merge(parameters))
|
|
44
48
|
Client.validate_model(parameters[:model])
|
|
45
49
|
end
|
|
46
50
|
|
|
@@ -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
|