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.
Files changed (56) hide show
  1. checksums.yaml +4 -4
  2. data/.yardopts +9 -0
  3. data/Rakefile +21 -1
  4. data/docs/ASSISTANTS.md +594 -0
  5. data/docs/CHANGELOG.md +243 -0
  6. data/docs/README.md +190 -12
  7. data/lib/nitro_intelligence/assistant.rb +82 -3
  8. data/lib/nitro_intelligence/assistant_registry.rb +18 -0
  9. data/lib/nitro_intelligence/assistants.rb +340 -102
  10. data/lib/nitro_intelligence/client/base.rb +88 -8
  11. data/lib/nitro_intelligence/client/client.rb +22 -2
  12. data/lib/nitro_intelligence/client/factory.rb +12 -3
  13. data/lib/nitro_intelligence/client/handlers/audio_transcription_handler.rb +3 -1
  14. data/lib/nitro_intelligence/client/handlers/base_handler.rb +17 -2
  15. data/lib/nitro_intelligence/client/handlers/chat_handler.rb +3 -1
  16. data/lib/nitro_intelligence/client/handlers/decision_handler.rb +75 -0
  17. data/lib/nitro_intelligence/client/handlers/image_handler.rb +6 -2
  18. data/lib/nitro_intelligence/client/handlers/observed/audio_transcription_handler.rb +30 -12
  19. data/lib/nitro_intelligence/client/handlers/observed/chat_handler.rb +16 -3
  20. data/lib/nitro_intelligence/client/handlers/observed/decision_handler.rb +72 -0
  21. data/lib/nitro_intelligence/client/handlers/observed/image_handler.rb +20 -5
  22. data/lib/nitro_intelligence/client/handlers/observed/text_to_speech_handler.rb +27 -9
  23. data/lib/nitro_intelligence/client/handlers/text_to_speech_handler.rb +3 -1
  24. data/lib/nitro_intelligence/client/observed.rb +8 -0
  25. data/lib/nitro_intelligence/client/observers/langfuse_observer.rb +35 -7
  26. data/lib/nitro_intelligence/configuration.rb +48 -0
  27. data/lib/nitro_intelligence/decision.rb +105 -0
  28. data/lib/nitro_intelligence/deprecator.rb +10 -0
  29. data/lib/nitro_intelligence/langfuse_extension.rb +1 -0
  30. data/lib/nitro_intelligence/langfuse_tracer_provider.rb +7 -6
  31. data/lib/nitro_intelligence/media/audio.rb +6 -0
  32. data/lib/nitro_intelligence/media/image.rb +14 -1
  33. data/lib/nitro_intelligence/media/image_generation.rb +14 -2
  34. data/lib/nitro_intelligence/media/media.rb +4 -0
  35. data/lib/nitro_intelligence/models/model.rb +7 -0
  36. data/lib/nitro_intelligence/models/model_catalog.rb +3 -0
  37. data/lib/nitro_intelligence/models/model_factory.rb +2 -0
  38. data/lib/nitro_intelligence/null_cache.rb +2 -0
  39. data/lib/nitro_intelligence/observability/project.rb +41 -2
  40. data/lib/nitro_intelligence/observability/project_client.rb +10 -1
  41. data/lib/nitro_intelligence/observability/project_client_registry.rb +18 -0
  42. data/lib/nitro_intelligence/observability/prompt.rb +4 -3
  43. data/lib/nitro_intelligence/observability/prompt_resolver.rb +2 -0
  44. data/lib/nitro_intelligence/observability/prompt_store.rb +9 -0
  45. data/lib/nitro_intelligence/observability/upload_handler.rb +3 -1
  46. data/lib/nitro_intelligence/pending_review.rb +257 -0
  47. data/lib/nitro_intelligence/railtie.rb +10 -0
  48. data/lib/nitro_intelligence/reporter.rb +29 -0
  49. data/lib/nitro_intelligence/review_decision.rb +36 -0
  50. data/lib/nitro_intelligence/run_result.rb +160 -0
  51. data/lib/nitro_intelligence/tool_call_review_interrupt.rb +9 -13
  52. data/lib/nitro_intelligence/tool_call_review_validator.rb +6 -8
  53. data/lib/nitro_intelligence/trace.rb +8 -0
  54. data/lib/nitro_intelligence/version.rb +2 -1
  55. data/lib/nitro_intelligence.rb +40 -0
  56. 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
- # Input images should be byte strings. Returns NitroIntelligence::ImageGeneration
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
- # Audio file should be file object with extension in the filename
25
- # Use file_extension for now to prevent a library dependency
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
- # Returns StringIO
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
- def self.new(observability_project_slug: nil)
6
- Factory.new(observability_project_slug:).build
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: NitroIntelligence.config.inference_api_key,
48
- base_url: NitroIntelligence.config.inference_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 = (parameters[:request_options] ||= {})
87
- (request_options[:extra_headers] ||= {}).merge!(headers.compact)
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
- parameters.replace(default_parameters.merge(parameters))
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
- # Modifies message in place
21
- prompt = handle_prompt(message:, parameters:)
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: @observer.project_client.project.prompt_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
- prompt
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
- def workflow(generation:, message:, audio_file:, parameters:)
59
- audio_transcription = @base_handler.perform_request(
60
- audio_file:, message:, parameters:, correlation_trace_id: generation.trace_id
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: @observer.project_client.project.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 || @observer.project_client.project.slug
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: @observer.project_client.project.prompt_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(parameters:, correlation_trace_id: generation.trace_id)
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