nitro_intelligence 2.1.0 → 2.3.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7d1c0e6c9ddffc032a989ebffb001d58aefabf86e50f92966a6d384a5218628f
4
- data.tar.gz: 60b9ae5ef4fd8c7a79bc45f13a1b965c9d9a0792f38bc285e361822b7d91a8ad
3
+ metadata.gz: 2c196d3b804d5a9137a2af97b45df17a137382114b2d592d835d3477af0541e6
4
+ data.tar.gz: f0fb2d024492cd2f04b52d12f41930fc0ac9ed323aca3681a59016eccc3db7fb
5
5
  SHA512:
6
- metadata.gz: a13a9b1a0a58f58c142ab87ec89f491f409341717b3101b9bb1579c88b8ed9335413ea73d621c489749b7d151d62672e9277e3d39da80c4721008cad28a301b0
7
- data.tar.gz: 1c498a0a4ec99af32f039dd43272ad61650da433c4f4d3b4297aadd473fba4c93af8d9ed08db0d2faf5012ed3a721e6d47587bf5429eb16368161d224f773cf7
6
+ metadata.gz: c41e6aa4592a56a75a7a45715ffc2a313f0a1ecf39c227a780334d60b204292790bb715da83783763931da5af95d1e845dffb3addc208329c9dbd01ed2f75d0b
7
+ data.tar.gz: 7eb926cd024f0b73c2b372e75d65a43cf51a82f7afd487cd589b61be0f4130549d3fe08c3f9a01965668e092f092a5d431423af925533997f863c88f70c7534c
data/docs/README.md CHANGED
@@ -36,28 +36,28 @@ NitroIntelligence.configure do |config|
36
36
 
37
37
  # Model configuration
38
38
  config.model_config = {
39
- "default_audio_transcription_model" => "gpt-4o-transcribe",
40
- "default_text_model" => "gpt-4o-mini",
41
- "default_image_model" => "nano-banana-2",
42
- "default_text_to_speech_model" => "gpt-4o-mini-tts",
39
+ "default_audio_transcription_model" => "Qwen/Qwen3-ASR-1.7B",
40
+ "default_text_model" => "Qwen/Qwen3.6-35B-A3B",
41
+ "default_image_model" => "google/gemini-3.1-flash-image",
42
+ "default_text_to_speech_model" => "openai/gpt-4o-mini-tts",
43
43
  "models" => [
44
44
  {
45
- "name" => "gpt-4o-mini",
45
+ "name" => "Qwen/Qwen3.6-35B-A3B",
46
46
  "type" => "text"
47
47
  },
48
48
  {
49
- "name" => "gpt-4o-transcribe",
49
+ "name" => "Qwen/Qwen3-ASR-1.7B",
50
50
  "type" => "audio_transcription"
51
51
  },
52
52
  {
53
- "name" => "nano-banana-2",
53
+ "name" => "google/gemini-3.1-flash-image",
54
54
  "type" => "image",
55
- "aspect_ratios" => ["1:1", "2:3", "3:2", "3:4", "4:3"],
55
+ "aspect_ratios" => ["1:1", "2:3", "3:2", "3:4", "4:3", "9:16", "16:9"],
56
56
  "resolutions" => ["512", "1K", "2K"],
57
57
  "omit_output_fields" => ["provider_specific_fields.thought_signatures"]
58
58
  },
59
59
  {
60
- "name" => "gpt-4o-mini-tts",
60
+ "name" => "openai/gpt-4o-mini-tts",
61
61
  "type" => "text_to_speech",
62
62
  "default_voice" => "marin",
63
63
  "default_response_format" => "mp3",
@@ -101,7 +101,7 @@ You may also use [`openai-ruby`](https://github.com/openai/openai-ruby) compatib
101
101
 
102
102
  ```ruby
103
103
  client = NitroIntelligence::Client.new
104
- client.chat(parameters: { model: "meta-llama/Llama-3.1-8B-Instruct", messages: [{ role: "user", content: "Why is the sky blue?" }]})
104
+ client.chat(parameters: { model: "Qwen/Qwen3.8-27B", messages: [{ role: "user", content: "Why is the sky blue?" }]})
105
105
  ```
106
106
 
107
107
  #### Providing Parameters
@@ -110,7 +110,7 @@ Parameters such as 'max_tokens' and 'temperature' can be passed in under the `pa
110
110
 
111
111
  ```ruby
112
112
  client = NitroIntelligence::Client.new
113
- client.chat(parameters: { model: "meta-llama/Llama-3.1-8B-Instruct", max_tokens: 1000, temperature: 0.7, messages: [{ role: "user", content: "Why is the sky blue?" }]})
113
+ client.chat(parameters: { model: "Qwen/Qwen3.8-27B", max_tokens: 1000, temperature: 0.7, messages: [{ role: "user", content: "Why is the sky blue?" }]})
114
114
  ```
115
115
 
116
116
  For a full list of supported parameters, see the [API reference here](https://developers.openai.com/api/reference/resources/completions/methods/create).
@@ -254,6 +254,49 @@ client.chat(
254
254
 
255
255
  If no `prompt_label` or `prompt_version` is provided, the 'production' prompt is used by default.
256
256
 
257
+ ### Prompt Fallbacks
258
+
259
+ Pass `prompt_fallback_name` to name a second prompt to use when the one you asked for isn't there. The prompt in `prompt_name` is tried first; if it doesn't exist, or its lookup fails, the fallback is used instead:
260
+
261
+ ```ruby
262
+ client = NitroIntelligence::Client.new(observability_project_slug: "fake-feature-project")
263
+ client.chat(
264
+ message: "Why is the sky blue?",
265
+ parameters: {
266
+ prompt_name: "My Prompt - Scheduling", # used when it exists
267
+ prompt_fallback_name: "My Prompt", # used when it doesn't
268
+ # prompt_fallback_label: "debug",
269
+ # prompt_fallback_version: "v2",
270
+ prompt_variables: {
271
+ appointment_id: "1234",
272
+ },
273
+ }
274
+ )
275
+ ```
276
+
277
+ This is for shipping a variant of a prompt — per domain, per locale, per experiment — without having to create one for every caller. Build the specific name however your feature likes; the fallback is just the prompt you'd have asked for anyway.
278
+
279
+ In practice the caller composes the specific name from something it already knows. Summarising a call, for example: each queue holds a different kind of conversation, so a queue that needs its own wording can have its own prompt while the rest use the general one.
280
+
281
+ ```ruby
282
+ base = "call_summary"
283
+ queue = call.queue.slug # "sales", "support", "collections", ...
284
+
285
+ client.chat(
286
+ parameters: {
287
+ prompt_name: [base, queue].compact.join("."), # call_summary.collections
288
+ prompt_fallback_name: base,
289
+ prompt_variables: { transcript: call.transcript },
290
+ }
291
+ )
292
+ ```
293
+
294
+ The fallback is looked up at its own `prompt_fallback_label` and `prompt_fallback_version`, and inherits neither from the prompt you asked for first. They describe a different prompt: a version number is minted per prompt, so version 3 of one name is an unrelated revision of another, and a label the first prompt carries need not exist on the fallback at all.
295
+
296
+ That means an omitted `prompt_fallback_label` resolves the fallback the same way any unlabelled lookup does — at the 'production' label. **If you want both prompts to come from the same label, name it for both.**
297
+
298
+ Whichever prompt is used is the one linked to the trace and merged into your request. If the fallback is missing too, the error is raised rather than swallowed — a request that asked for a prompt shouldn't quietly run without one.
299
+
257
300
  ### Custom Trace Names
258
301
 
259
302
  To provide custom trace names to the observability platform, you can pass 'trace_name' in parameters. Example:
@@ -287,6 +330,72 @@ client.chat(
287
330
  )
288
331
  ```
289
332
 
333
+ ### Grouping Traces: Sessions and Tags
334
+
335
+ `session_id` groups related traces in the observability platform, and `tags` label
336
+ them for filtering. Both are optional and are only sent when you set them.
337
+
338
+ ```ruby
339
+ client = NitroIntelligence::Client.new(observability_project_slug: "fake-feature-project")
340
+ client.chat(
341
+ message: "why did this deploy fail?",
342
+ parameters: {
343
+ session_id: "deploy-9f2c1ab",
344
+ tags: ["deployment-failure-analyzer"],
345
+ }
346
+ )
347
+ ```
348
+
349
+ ### Correlating With Application Logs and the Inference Gateway
350
+
351
+ Every observed request is correlated across three systems automatically:
352
+
353
+ * **Observability platform → inference gateway.** The trace ID is sent to the
354
+ gateway as `x-litellm-trace-id`, and `metadata` is sent as
355
+ `x-litellm-spend-logs-metadata` (dropped if it exceeds 4KB).
356
+
357
+ The two are independent. Metadata is sent whenever you set it, observed or not,
358
+ so gateway spend can be attributed even without observability. The trace ID is
359
+ sent only on the observed path - it comes from the observation being recorded,
360
+ never from whatever span happens to be active, so a client built without an
361
+ `observability_project_slug` sends none even inside a host application with its
362
+ own OpenTelemetry instrumentation.
363
+ * **Inference gateway → observability platform.** When a request fails, the
364
+ gateway's own request identifier is read from the error response and recorded on
365
+ the observation as `litellm_call_id` metadata.
366
+ * **Application logs → observability platform.** Put whatever your logs are keyed
367
+ by into `metadata` — it lands on the trace *and* in the gateway's spend logs.
368
+
369
+ ```ruby
370
+ client.chat(
371
+ message: "why did this deploy fail?",
372
+ parameters: {
373
+ trace_seed: deploy_url,
374
+ metadata: {
375
+ source: self.class,
376
+ rails_request_id: request_id,
377
+ job_id: job_id,
378
+ },
379
+ }
380
+ )
381
+ ```
382
+
383
+ To log the trace ID from application code, derive it from the same seed with
384
+ `NitroIntelligence::Trace.create_id(seed:)`.
385
+
386
+ #### Failed Requests
387
+
388
+ A request that raises still produces a usable trace. The observation records:
389
+
390
+ * the `input` that was sent (recorded before the request runs)
391
+ * `level: "ERROR"` and a `status_message` carrying the exception class and message
392
+ * `litellm_call_id` metadata when the gateway returned one
393
+
394
+ The exception is then re-raised, so this changes what is observed, not how callers
395
+ handle failures. Image generation is the one exception to input recording: its
396
+ input carries base64 payloads that are replaced with media references only on
397
+ success, so it records no input up front.
398
+
290
399
  ### Scoring
291
400
 
292
401
  You can use `NitroIntelligence::Reporter` to evaluate existing traces. Calling `NitroIntelligence::Reporter#score` lets you attach metrics to a trace in the observability platform.
@@ -337,18 +446,18 @@ Consider this prompt config:
337
446
 
338
447
  ```json
339
448
  {
340
- "model": "gpt-4o-mini"
449
+ "model": "Qwen/Qwen3.6-35B-A3B"
341
450
  }
342
451
  ```
343
452
 
344
- Invoking this request would result in "gpt-4o-mini" being used as the model, even if supplied manually:
453
+ Invoking this request would result in "Qwen/Qwen3.6-35B-A3B" being used as the model, even if supplied manually:
345
454
 
346
455
  ```ruby
347
456
  client = NitroIntelligence::Client.new(observability_project_slug: "fake-feature-project")
348
457
  client.chat(
349
458
  message: "Where is the appointment?",
350
459
  parameters: {
351
- model: "meta-llama/Llama-3.1-8B-Instruct", # Will not be used, will be overridden by config "gpt-4o-mini"
460
+ model: "Qwen/Qwen3.8-27B", # Will not be used, will be overridden by config "Qwen/Qwen3.6-35B-A3B"
352
461
  prompt_name: "My Prompt With Variables",
353
462
  prompt_variables: {
354
463
  appointment_id: "1234",
@@ -367,7 +476,7 @@ client = NitroIntelligence::Client.new(observability_project_slug: "fake-feature
367
476
  client.chat(
368
477
  message: "Where is the appointment?",
369
478
  parameters: {
370
- model: "meta-llama/Llama-3.1-8B-Instruct", # This will now be used since "prompt_config_disabled" is true
479
+ model: "Qwen/Qwen3.8-27B", # This will now be used since "prompt_config_disabled" is true
371
480
  prompt_name: "My Prompt With Variables",
372
481
  prompt_variables: {
373
482
  appointment_id: "1234",
@@ -12,8 +12,9 @@ module NitroIntelligence
12
12
  perform_request(audio_file:, message:, parameters:)
13
13
  end
14
14
 
15
- def perform_request(audio_file:, message: "", parameters: {})
15
+ def perform_request(audio_file:, message: "", parameters: {}, correlation_trace_id: nil)
16
16
  add_request_headers(parameters, MODALITY_HEADER => "audio", REQUESTED_MODEL_HEADER => parameters[:model])
17
+ add_correlation_headers(parameters, trace_id: correlation_trace_id)
17
18
  @client.audio.transcriptions.create(
18
19
  prompt: message,
19
20
  file: audio_file,
@@ -1,3 +1,5 @@
1
+ require "json"
2
+
1
3
  module NitroIntelligence
2
4
  module Client
3
5
  module Handlers
@@ -5,6 +7,15 @@ module NitroIntelligence
5
7
  MODALITY_HEADER = "nip-modality".freeze
6
8
  REQUESTED_MODEL_HEADER = "nip-requested-model".freeze
7
9
 
10
+ # Correlation headers understood by the inference gateway (LiteLLM).
11
+ # See https://docs.litellm.ai/docs/proxy/request_headers
12
+ TRACE_ID_HEADER = "x-litellm-trace-id".freeze
13
+ SPEND_LOGS_METADATA_HEADER = "x-litellm-spend-logs-metadata".freeze
14
+
15
+ # Headers over ~8KB are rejected by most proxies. Metadata is caller
16
+ # supplied, so cap it rather than turning a large hash into a failed request.
17
+ MAX_SPEND_LOGS_METADATA_BYTES = 4096
18
+
8
19
  def initialize(client:)
9
20
  @client = client
10
21
  end
@@ -16,6 +27,41 @@ module NitroIntelligence
16
27
  (request_options[:extra_headers] ||= {}).merge!(headers.compact)
17
28
  parameters
18
29
  end
30
+
31
+ # Hands the inference gateway what it needs to be matched up with the rest of
32
+ # the picture: the trace ID the observability platform is recording this
33
+ # request under, so a Langfuse trace can be found from a LiteLLM request even
34
+ # when the request fails and never produces a response body, and the caller's
35
+ # metadata, so gateway spend can be attributed to the work that caused it.
36
+ #
37
+ # The two are independent. Metadata is worth sending whether or not anything
38
+ # is observing, whereas the trace ID is supplied by the observed handlers and
39
+ # is never read from whatever tracing context happens to be active: a host
40
+ # application with its own instrumentation has traces of its own, and their
41
+ # IDs mean nothing to the observability platform.
42
+ #
43
+ # add_request_headers drops nil values, so each header appears only when it
44
+ # has something to say.
45
+ def add_correlation_headers(parameters, trace_id:)
46
+ add_request_headers(
47
+ parameters,
48
+ TRACE_ID_HEADER => trace_id.presence,
49
+ SPEND_LOGS_METADATA_HEADER => spend_logs_metadata(parameters[:metadata])
50
+ )
51
+ end
52
+
53
+ def spend_logs_metadata(metadata)
54
+ return nil if metadata.blank?
55
+
56
+ json = metadata.to_json
57
+ return json if json.bytesize <= MAX_SPEND_LOGS_METADATA_BYTES
58
+
59
+ NitroIntelligence.logger.warn(
60
+ "#{self.class} metadata is #{json.bytesize} bytes, over the " \
61
+ "#{MAX_SPEND_LOGS_METADATA_BYTES} byte #{SPEND_LOGS_METADATA_HEADER} limit - omitting it"
62
+ )
63
+ nil
64
+ end
19
65
  end
20
66
  end
21
67
  end
@@ -12,8 +12,9 @@ module NitroIntelligence
12
12
  perform_request(parameters:)
13
13
  end
14
14
 
15
- def perform_request(parameters: {})
15
+ def perform_request(parameters: {}, correlation_trace_id: nil)
16
16
  add_request_headers(parameters, REQUESTED_MODEL_HEADER => parameters[:model])
17
+ add_correlation_headers(parameters, trace_id: correlation_trace_id)
17
18
  @client.chat.completions.create(**parameters.slice(*ALLOWED_EXTRA_PARAMETERS))
18
19
  end
19
20
 
@@ -19,8 +19,9 @@ module NitroIntelligence
19
19
  image_generation
20
20
  end
21
21
 
22
- def perform_request(parameters: {})
22
+ def perform_request(parameters: {}, correlation_trace_id: nil)
23
23
  add_request_headers(parameters, MODALITY_HEADER => "image", REQUESTED_MODEL_HEADER => parameters[:model])
24
+ add_correlation_headers(parameters, trace_id: correlation_trace_id)
24
25
  @client.chat.completions.create(**parameters.slice(*ALLOWED_EXTRA_PARAMETERS))
25
26
  end
26
27
 
@@ -1,5 +1,6 @@
1
1
  require "base64"
2
2
  require "nitro_intelligence/media/audio"
3
+ require "nitro_intelligence/observability/prompt_resolver"
3
4
 
4
5
  module NitroIntelligence
5
6
  module Client
@@ -25,7 +26,8 @@ module NitroIntelligence
25
26
  type: :generation,
26
27
  parameters:,
27
28
  trace_name:,
28
- prompt:
29
+ prompt:,
30
+ input: message
29
31
  ) do |generation|
30
32
  workflow(generation:, message:, audio_file:, parameters:)
31
33
  end
@@ -34,32 +36,29 @@ module NitroIntelligence
34
36
  private
35
37
 
36
38
  def handle_prompt(message:, parameters:)
37
- return nil if parameters[:prompt_name].blank?
38
-
39
- prompt = @observer.project_client.project.prompt_store.get_prompt(
40
- prompt_name: parameters[:prompt_name],
41
- prompt_label: parameters[:prompt_label],
42
- prompt_version: parameters[:prompt_version]
39
+ prompt = NitroIntelligence::Observability::PromptResolver.for(
40
+ store: @observer.project_client.project.prompt_store,
41
+ parameters:
43
42
  )
44
- prompt_variables = parameters[:prompt_variables] || {}
45
-
46
- if prompt.present?
47
- # Prompts for audio transcriptions should only be text
48
- if prompt.type != "text"
49
- raise ObservedAudioTranscriptionPromptError,
50
- "Prompt type for audio transcription must be text: #{prompt.name}"
51
- end
52
- interpolated_prompt = prompt.compile(**prompt_variables)
43
+ return nil if prompt.blank?
53
44
 
54
- message.prepend("#{interpolated_prompt} ").strip!
55
- parameters.merge!(prompt.config) unless parameters[:prompt_config_disabled]
45
+ # Prompts for audio transcriptions should only be text
46
+ if prompt.type != "text"
47
+ raise ObservedAudioTranscriptionPromptError,
48
+ "Prompt type for audio transcription must be text: #{prompt.name}"
56
49
  end
50
+ interpolated_prompt = prompt.compile(**(parameters[:prompt_variables] || {}))
51
+
52
+ message.prepend("#{interpolated_prompt} ").strip!
53
+ parameters.merge!(prompt.config) unless parameters[:prompt_config_disabled]
57
54
 
58
55
  prompt
59
56
  end
60
57
 
61
58
  def workflow(generation:, message:, audio_file:, parameters:)
62
- audio_transcription = @base_handler.perform_request(audio_file:, message:, parameters:)
59
+ audio_transcription = @base_handler.perform_request(
60
+ audio_file:, message:, parameters:, correlation_trace_id: generation.trace_id
61
+ )
63
62
 
64
63
  audio_file.rewind
65
64
  upload_handler = NitroIntelligence::Observability::UploadHandler.new(
@@ -70,9 +69,10 @@ module NitroIntelligence
70
69
  upload_queue: Queue.new([NitroIntelligence::Audio.new(audio_file)])
71
70
  )
72
71
 
72
+ # The model and input are already recorded on the observation before the
73
+ # request runs, and the response carries no model of its own
74
+ # (OpenAI::Models::Audio::Transcription), so neither is set again here.
73
75
  trace_attributes = {
74
- model: parameters[:model], # Model isn't in response object OpenAI::Models::Audio::Transcription
75
- input: message,
76
76
  output: audio_transcription.text,
77
77
  usage_details: {
78
78
  input_tokens: audio_transcription.usage.input_tokens,
@@ -1,3 +1,5 @@
1
+ require "nitro_intelligence/observability/prompt_resolver"
2
+
1
3
  module NitroIntelligence
2
4
  module Client
3
5
  module Handlers
@@ -19,44 +21,38 @@ module NitroIntelligence
19
21
  type: :generation,
20
22
  parameters:,
21
23
  trace_name:,
22
- prompt:
23
- ) do |_generation|
24
- workflow(parameters:)
24
+ prompt:,
25
+ input: parameters[:messages]
26
+ ) do |generation|
27
+ workflow(generation:, parameters:)
25
28
  end
26
29
  end
27
30
 
28
31
  private
29
32
 
30
33
  def handle_prompt(parameters:)
31
- return nil if parameters[:prompt_name].blank?
32
-
33
- prompt = @observer.project_client.project.prompt_store.get_prompt(
34
- prompt_name: parameters[:prompt_name],
35
- prompt_label: parameters[:prompt_label],
36
- prompt_version: parameters[:prompt_version]
34
+ prompt = NitroIntelligence::Observability::PromptResolver.for(
35
+ store: @observer.project_client.project.prompt_store,
36
+ parameters:
37
37
  )
38
- prompt_variables = parameters[:prompt_variables] || {}
38
+ return nil if prompt.blank?
39
39
 
40
- if prompt.present?
41
- parameters[:messages] = prompt.interpolate(
42
- messages: parameters[:messages],
43
- variables: prompt_variables
44
- )
40
+ parameters[:messages] = prompt.interpolate(
41
+ messages: parameters[:messages],
42
+ variables: parameters[:prompt_variables] || {}
43
+ )
45
44
 
46
- parameters.merge!(prompt.config) unless parameters[:prompt_config_disabled]
47
- end
45
+ parameters.merge!(prompt.config) unless parameters[:prompt_config_disabled]
48
46
 
49
47
  prompt
50
48
  end
51
49
 
52
- def workflow(parameters:)
53
- chat_completion = @base_handler.perform_request(parameters:)
54
- input = parameters[:messages]
50
+ def workflow(generation:, parameters:)
51
+ chat_completion = @base_handler.perform_request(parameters:, correlation_trace_id: generation.trace_id)
55
52
  output = chat_completion.choices.first.message.to_h
56
53
 
57
54
  trace_attributes = {
58
55
  model: chat_completion.model,
59
- input:,
60
56
  output:,
61
57
  usage_details: {
62
58
  prompt_tokens: chat_completion.usage.prompt_tokens,
@@ -1,4 +1,5 @@
1
1
  require "nitro_intelligence/media/image_generation"
2
+ require "nitro_intelligence/observability/prompt_resolver"
2
3
 
3
4
  module NitroIntelligence
4
5
  module Client
@@ -58,29 +59,24 @@ module NitroIntelligence
58
59
  end
59
60
 
60
61
  def handle_prompt(parameters:)
61
- return nil if parameters[:prompt_name].blank?
62
-
63
- prompt = @observer.project_client.project.prompt_store.get_prompt(
64
- prompt_name: parameters[:prompt_name],
65
- prompt_label: parameters[:prompt_label],
66
- prompt_version: parameters[:prompt_version]
62
+ prompt = NitroIntelligence::Observability::PromptResolver.for(
63
+ store: @observer.project_client.project.prompt_store,
64
+ parameters:
67
65
  )
68
- prompt_variables = parameters[:prompt_variables] || {}
66
+ return nil if prompt.blank?
69
67
 
70
- if prompt.present?
71
- parameters[:messages] = prompt.interpolate(
72
- messages: parameters[:messages],
73
- variables: prompt_variables
74
- )
68
+ parameters[:messages] = prompt.interpolate(
69
+ messages: parameters[:messages],
70
+ variables: parameters[:prompt_variables] || {}
71
+ )
75
72
 
76
- parameters.merge!(prompt.config) unless parameters[:prompt_config_disabled]
77
- end
73
+ parameters.merge!(prompt.config) unless parameters[:prompt_config_disabled]
78
74
 
79
75
  prompt
80
76
  end
81
77
 
82
78
  def workflow(generation:, image_generation:, parameters:)
83
- chat_completion = @base_handler.perform_request(parameters:)
79
+ chat_completion = @base_handler.perform_request(parameters:, correlation_trace_id: generation.trace_id)
84
80
 
85
81
  image_generation.trace_id = generation.trace_id
86
82
  image_generation.parse_file(chat_completion)
@@ -1,4 +1,5 @@
1
1
  require "nitro_intelligence/media/audio"
2
+ require "nitro_intelligence/observability/prompt_resolver"
2
3
 
3
4
  module NitroIntelligence
4
5
  module Client
@@ -24,7 +25,8 @@ module NitroIntelligence
24
25
  type: :generation,
25
26
  parameters:,
26
27
  trace_name:,
27
- prompt:
28
+ prompt:,
29
+ input: message
28
30
  ) do |generation|
29
31
  workflow(message:, parameters:, trace_id: generation.trace_id)
30
32
  end
@@ -33,26 +35,21 @@ module NitroIntelligence
33
35
  private
34
36
 
35
37
  def handle_prompt(parameters:)
36
- return nil if parameters[:prompt_name].blank?
37
-
38
- prompt = @observer.project_client.project.prompt_store.get_prompt(
39
- prompt_name: parameters[:prompt_name],
40
- prompt_label: parameters[:prompt_label],
41
- prompt_version: parameters[:prompt_version]
38
+ prompt = NitroIntelligence::Observability::PromptResolver.for(
39
+ store: @observer.project_client.project.prompt_store,
40
+ parameters:
42
41
  )
43
- prompt_variables = parameters[:prompt_variables] || {}
44
-
45
- if prompt.present?
46
- # Prompts for tts should only be text
47
- if prompt.type != "text"
48
- raise ObservedTextToSpeechPromptError,
49
- "Prompt type for text-to-speech must be text: #{prompt.name}"
50
- end
51
- interpolated_prompt = prompt.compile(**prompt_variables)
52
-
53
- parameters.merge!(prompt.config) unless parameters[:prompt_config_disabled]
54
- parameters[:instructions] = interpolated_prompt
42
+ return nil if prompt.blank?
43
+
44
+ # Prompts for tts should only be text
45
+ if prompt.type != "text"
46
+ raise ObservedTextToSpeechPromptError,
47
+ "Prompt type for text-to-speech must be text: #{prompt.name}"
55
48
  end
49
+ interpolated_prompt = prompt.compile(**(parameters[:prompt_variables] || {}))
50
+
51
+ parameters.merge!(prompt.config) unless parameters[:prompt_config_disabled]
52
+ parameters[:instructions] = interpolated_prompt
56
53
 
57
54
  prompt
58
55
  end
@@ -71,7 +68,7 @@ module NitroIntelligence
71
68
  end
72
69
 
73
70
  def workflow(message:, parameters:, trace_id:)
74
- tts = @base_handler.perform_request(message:, parameters:)
71
+ tts = @base_handler.perform_request(message:, parameters:, correlation_trace_id: trace_id)
75
72
  output = ""
76
73
 
77
74
  Tempfile.create(["tts", ".#{parameters[:response_format]}"]) do |tempfile|
@@ -82,12 +79,10 @@ module NitroIntelligence
82
79
  output = handle_text_to_speech_upload(tempfile, trace_id)
83
80
  end
84
81
 
85
- # We only get StringIO object as a response
86
- # We dont have usage on tokens and the actual model that was used
87
- # We will log the requested model instead
82
+ # We only get StringIO object as a response, so there are no usage details
83
+ # and no resolved model to record. The requested model and the input are
84
+ # already on the observation from before the request ran.
88
85
  trace_attributes = {
89
- model: parameters[:model],
90
- input: message,
91
86
  output:,
92
87
  }
93
88
 
@@ -12,8 +12,9 @@ module NitroIntelligence
12
12
  perform_request(message:, parameters:)
13
13
  end
14
14
 
15
- def perform_request(message: "", parameters: {})
15
+ def perform_request(message: "", parameters: {}, correlation_trace_id: nil)
16
16
  add_request_headers(parameters, MODALITY_HEADER => "audio", REQUESTED_MODEL_HEADER => parameters[:model])
17
+ add_correlation_headers(parameters, trace_id: correlation_trace_id)
17
18
  @client.audio.speech.create(
18
19
  input: message,
19
20
  **parameters.slice(*ALLOWED_EXTRA_PARAMETERS)
@@ -2,16 +2,23 @@ module NitroIntelligence
2
2
  module Client
3
3
  module Observers
4
4
  class LangfuseObserver
5
+ # The inference gateway returns its own request identifier on every
6
+ # response, including error responses. Recording it on the observation is
7
+ # the only way to line a failed generation up with the gateway's logs.
8
+ # See https://docs.litellm.ai/docs/proxy/response_headers
9
+ LITELLM_CALL_ID_HEADER = "x-litellm-call-id".freeze
10
+
11
+ MAX_STATUS_MESSAGE_LENGTH = 2000
12
+
5
13
  attr_reader :project_client
6
14
 
7
15
  def initialize(project_client:)
8
16
  @project_client = project_client
9
17
  end
10
18
 
11
- def observe(operation_name, type:, parameters:, trace_name:, prompt: nil) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
19
+ def observe(operation_name, type:, parameters:, trace_name:, prompt: nil, input: nil) # rubocop:disable Metrics/AbcSize
12
20
  metadata = parameters[:metadata]
13
21
  seed = parameters[:trace_seed]
14
- user_id = parameters[:user_id] || NitroIntelligence.configuration.observability_user_id
15
22
  trace_id = NitroIntelligence::Trace.create_id(seed:) if seed.present?
16
23
 
17
24
  if prompt
@@ -21,10 +28,7 @@ module NitroIntelligence
21
28
 
22
29
  metadata = metadata.transform_values(&:to_s)
23
30
 
24
- Langfuse.propagate_attributes(
25
- user_id:,
26
- metadata:
27
- ) do
31
+ Langfuse.propagate_attributes(**propagated_attributes(parameters, metadata)) do
28
32
  @project_client.observability_client.observe(
29
33
  operation_name,
30
34
  as_type: type,
@@ -35,8 +39,9 @@ module NitroIntelligence
35
39
  ) do |generation|
36
40
  generation.update_trace(name: trace_name, release: NitroIntelligence.configuration.current_revision)
37
41
  generation.update({ prompt: { name: prompt.name, version: prompt.version } }) if prompt
42
+ record_input(generation, input)
38
43
 
39
- result, trace_attributes = yield(generation)
44
+ result, trace_attributes = observe_failures(generation) { yield(generation) }
40
45
 
41
46
  if trace_attributes
42
47
  handle_truncation(trace_attributes[:input], trace_attributes[:output], trace_attributes[:model])
@@ -56,6 +61,56 @@ module NitroIntelligence
56
61
 
57
62
  private
58
63
 
64
+ # Recorded before the request is made so that a request which raises still
65
+ # shows what was sent. Handlers whose input is not safe to record twice
66
+ # (image generation sends base64 payloads that are replaced with media
67
+ # references on success) pass no input and rely on the failure record alone.
68
+ def record_input(generation, input)
69
+ return if input.blank?
70
+
71
+ generation.input = input
72
+ generation.update_trace(input:)
73
+ end
74
+
75
+ # `session_id` and `tags` are only forwarded when set so that callers who
76
+ # pass neither keep the existing propagation payload.
77
+ def propagated_attributes(parameters, metadata)
78
+ attributes = {
79
+ user_id: parameters[:user_id] || NitroIntelligence.configuration.observability_user_id,
80
+ metadata:,
81
+ }
82
+ attributes[:session_id] = parameters[:session_id] if parameters[:session_id].present?
83
+ attributes[:tags] = parameters[:tags] if parameters[:tags].present?
84
+ attributes
85
+ end
86
+
87
+ # Without this, a request that raises leaves an observation carrying only
88
+ # its name and model - no input, no output, no indication anything went
89
+ # wrong - because langfuse-rb ends the span in an `ensure` and never
90
+ # records the exception.
91
+ def observe_failures(generation)
92
+ yield
93
+ rescue => e
94
+ record_failure(generation, e)
95
+ raise
96
+ end
97
+
98
+ def record_failure(generation, error)
99
+ generation.update(
100
+ level: "ERROR",
101
+ status_message: "#{error.class}: #{error.message}".truncate(MAX_STATUS_MESSAGE_LENGTH)
102
+ )
103
+
104
+ call_id = litellm_call_id(error)
105
+ generation.metadata = { litellm_call_id: call_id } if call_id
106
+ end
107
+
108
+ def litellm_call_id(error)
109
+ return nil unless error.respond_to?(:headers)
110
+
111
+ error.headers&.[](LITELLM_CALL_ID_HEADER)
112
+ end
113
+
59
114
  def handle_truncation(_input, output, model_name)
60
115
  model = NitroIntelligence.model_catalog.lookup_by_name(model_name)
61
116
 
@@ -0,0 +1,69 @@
1
+ require "nitro_intelligence/observability/prompt_store"
2
+
3
+ module NitroIntelligence
4
+ module Observability
5
+ # Resolves a managed prompt from the lookups a request asks for, most specific first. A lookup that is
6
+ # missing -- or that fails -- yields to the next; the last one's failure propagates.
7
+ class PromptResolver
8
+ # One prompt lookup: the name asked for, at a label or a version.
9
+ Lookup = Data.define(:name, :label, :version)
10
+
11
+ def self.for(store:, parameters:)
12
+ new(store: store, lookups: lookups_for(parameters)).prompt
13
+ end
14
+
15
+ # The fallback names its own label and version and inherits neither: a version is minted per prompt,
16
+ # and a label the requested prompt carries need not exist on the fallback at all.
17
+ def self.lookups_for(parameters)
18
+ [
19
+ Lookup.new(
20
+ name: parameters[:prompt_name],
21
+ label: parameters[:prompt_label],
22
+ version: parameters[:prompt_version]
23
+ ),
24
+ Lookup.new(
25
+ name: parameters[:prompt_fallback_name],
26
+ label: parameters[:prompt_fallback_label],
27
+ version: parameters[:prompt_fallback_version]
28
+ ),
29
+ ]
30
+ end
31
+
32
+ def initialize(store:, lookups:)
33
+ @store = store
34
+ @lookups = lookups.select { |lookup| lookup.name.present? }.uniq
35
+ end
36
+
37
+ def prompt
38
+ *optional, final = @lookups
39
+ return nil if final.nil?
40
+
41
+ optional.each do |lookup|
42
+ resolved = fetch_optional(lookup)
43
+ return resolved if resolved
44
+ end
45
+
46
+ fetch(final)
47
+ end
48
+
49
+ private
50
+
51
+ def fetch(lookup)
52
+ @store.get_prompt(
53
+ prompt_name: lookup.name,
54
+ prompt_label: lookup.label,
55
+ prompt_version: lookup.version
56
+ )
57
+ end
58
+
59
+ def fetch_optional(lookup)
60
+ fetch(lookup)
61
+ rescue => e
62
+ NitroIntelligence.logger.info(
63
+ "#{self.class} #{e} - Falling back to the next prompt name after a failed lookup: #{lookup.name}"
64
+ )
65
+ nil
66
+ end
67
+ end
68
+ end
69
+ end
@@ -1,3 +1,3 @@
1
1
  module NitroIntelligence
2
- VERSION = "2.1.0".freeze
2
+ VERSION = "2.3.0".freeze
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: nitro_intelligence
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.1.0
4
+ version: 2.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Igor Artemenko
@@ -105,6 +105,7 @@ files:
105
105
  - lib/nitro_intelligence/observability/project_client.rb
106
106
  - lib/nitro_intelligence/observability/project_client_registry.rb
107
107
  - lib/nitro_intelligence/observability/prompt.rb
108
+ - lib/nitro_intelligence/observability/prompt_resolver.rb
108
109
  - lib/nitro_intelligence/observability/prompt_store.rb
109
110
  - lib/nitro_intelligence/observability/upload_handler.rb
110
111
  - lib/nitro_intelligence/reporter.rb