nitro_intelligence 2.2.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: 1181b17cc855ffa2f039b1401fd5dd2040c262d2878e6d265e65f9a581f6106e
4
- data.tar.gz: 28b3a022b8d6d7e8e8472930fcf089fa5a3ead8a6c0d917e7587a73b69b8fba5
3
+ metadata.gz: 2c196d3b804d5a9137a2af97b45df17a137382114b2d592d835d3477af0541e6
4
+ data.tar.gz: f0fb2d024492cd2f04b52d12f41930fc0ac9ed323aca3681a59016eccc3db7fb
5
5
  SHA512:
6
- metadata.gz: e5fd5911aa6740d71f9dc5564766fe80137d7c2da6e7195ee406649387a22a8387bc02db78671e643d90445c6b8578e2b7b47d7573d4abdf677b35edcc41c6a3
7
- data.tar.gz: d11585c4f9731911910660e67eb8702634475fd7bbf523b7313c81910d2775852f918a789a669719840196d64c474c1452842f6948e7618563ee1d5b2a1cd5e1
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).
@@ -330,6 +330,72 @@ client.chat(
330
330
  )
331
331
  ```
332
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
+
333
399
  ### Scoring
334
400
 
335
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.
@@ -380,18 +446,18 @@ Consider this prompt config:
380
446
 
381
447
  ```json
382
448
  {
383
- "model": "gpt-4o-mini"
449
+ "model": "Qwen/Qwen3.6-35B-A3B"
384
450
  }
385
451
  ```
386
452
 
387
- 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:
388
454
 
389
455
  ```ruby
390
456
  client = NitroIntelligence::Client.new(observability_project_slug: "fake-feature-project")
391
457
  client.chat(
392
458
  message: "Where is the appointment?",
393
459
  parameters: {
394
- 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"
395
461
  prompt_name: "My Prompt With Variables",
396
462
  prompt_variables: {
397
463
  appointment_id: "1234",
@@ -410,7 +476,7 @@ client = NitroIntelligence::Client.new(observability_project_slug: "fake-feature
410
476
  client.chat(
411
477
  message: "Where is the appointment?",
412
478
  parameters: {
413
- 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
414
480
  prompt_name: "My Prompt With Variables",
415
481
  prompt_variables: {
416
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
 
@@ -26,7 +26,8 @@ module NitroIntelligence
26
26
  type: :generation,
27
27
  parameters:,
28
28
  trace_name:,
29
- prompt:
29
+ prompt:,
30
+ input: message
30
31
  ) do |generation|
31
32
  workflow(generation:, message:, audio_file:, parameters:)
32
33
  end
@@ -55,7 +56,9 @@ module NitroIntelligence
55
56
  end
56
57
 
57
58
  def workflow(generation:, message:, audio_file:, parameters:)
58
- 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
+ )
59
62
 
60
63
  audio_file.rewind
61
64
  upload_handler = NitroIntelligence::Observability::UploadHandler.new(
@@ -66,9 +69,10 @@ module NitroIntelligence
66
69
  upload_queue: Queue.new([NitroIntelligence::Audio.new(audio_file)])
67
70
  )
68
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.
69
75
  trace_attributes = {
70
- model: parameters[:model], # Model isn't in response object OpenAI::Models::Audio::Transcription
71
- input: message,
72
76
  output: audio_transcription.text,
73
77
  usage_details: {
74
78
  input_tokens: audio_transcription.usage.input_tokens,
@@ -21,9 +21,10 @@ module NitroIntelligence
21
21
  type: :generation,
22
22
  parameters:,
23
23
  trace_name:,
24
- prompt:
25
- ) do |_generation|
26
- workflow(parameters:)
24
+ prompt:,
25
+ input: parameters[:messages]
26
+ ) do |generation|
27
+ workflow(generation:, parameters:)
27
28
  end
28
29
  end
29
30
 
@@ -46,14 +47,12 @@ module NitroIntelligence
46
47
  prompt
47
48
  end
48
49
 
49
- def workflow(parameters:)
50
- chat_completion = @base_handler.perform_request(parameters:)
51
- input = parameters[:messages]
50
+ def workflow(generation:, parameters:)
51
+ chat_completion = @base_handler.perform_request(parameters:, correlation_trace_id: generation.trace_id)
52
52
  output = chat_completion.choices.first.message.to_h
53
53
 
54
54
  trace_attributes = {
55
55
  model: chat_completion.model,
56
- input:,
57
56
  output:,
58
57
  usage_details: {
59
58
  prompt_tokens: chat_completion.usage.prompt_tokens,
@@ -76,7 +76,7 @@ module NitroIntelligence
76
76
  end
77
77
 
78
78
  def workflow(generation:, image_generation:, parameters:)
79
- chat_completion = @base_handler.perform_request(parameters:)
79
+ chat_completion = @base_handler.perform_request(parameters:, correlation_trace_id: generation.trace_id)
80
80
 
81
81
  image_generation.trace_id = generation.trace_id
82
82
  image_generation.parse_file(chat_completion)
@@ -25,7 +25,8 @@ module NitroIntelligence
25
25
  type: :generation,
26
26
  parameters:,
27
27
  trace_name:,
28
- prompt:
28
+ prompt:,
29
+ input: message
29
30
  ) do |generation|
30
31
  workflow(message:, parameters:, trace_id: generation.trace_id)
31
32
  end
@@ -67,7 +68,7 @@ module NitroIntelligence
67
68
  end
68
69
 
69
70
  def workflow(message:, parameters:, trace_id:)
70
- tts = @base_handler.perform_request(message:, parameters:)
71
+ tts = @base_handler.perform_request(message:, parameters:, correlation_trace_id: trace_id)
71
72
  output = ""
72
73
 
73
74
  Tempfile.create(["tts", ".#{parameters[:response_format]}"]) do |tempfile|
@@ -78,12 +79,10 @@ module NitroIntelligence
78
79
  output = handle_text_to_speech_upload(tempfile, trace_id)
79
80
  end
80
81
 
81
- # We only get StringIO object as a response
82
- # We dont have usage on tokens and the actual model that was used
83
- # 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.
84
85
  trace_attributes = {
85
- model: parameters[:model],
86
- input: message,
87
86
  output:,
88
87
  }
89
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
 
@@ -1,3 +1,3 @@
1
1
  module NitroIntelligence
2
- VERSION = "2.2.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.2.0
4
+ version: 2.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Igor Artemenko