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 +4 -4
- data/docs/README.md +81 -15
- data/lib/nitro_intelligence/client/handlers/audio_transcription_handler.rb +2 -1
- data/lib/nitro_intelligence/client/handlers/base_handler.rb +46 -0
- data/lib/nitro_intelligence/client/handlers/chat_handler.rb +2 -1
- data/lib/nitro_intelligence/client/handlers/image_handler.rb +2 -1
- data/lib/nitro_intelligence/client/handlers/observed/audio_transcription_handler.rb +8 -4
- data/lib/nitro_intelligence/client/handlers/observed/chat_handler.rb +6 -7
- data/lib/nitro_intelligence/client/handlers/observed/image_handler.rb +1 -1
- data/lib/nitro_intelligence/client/handlers/observed/text_to_speech_handler.rb +6 -7
- data/lib/nitro_intelligence/client/handlers/text_to_speech_handler.rb +2 -1
- data/lib/nitro_intelligence/client/observers/langfuse_observer.rb +62 -7
- data/lib/nitro_intelligence/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 2c196d3b804d5a9137a2af97b45df17a137382114b2d592d835d3477af0541e6
|
|
4
|
+
data.tar.gz: f0fb2d024492cd2f04b52d12f41930fc0ac9ed323aca3681a59016eccc3db7fb
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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" => "
|
|
40
|
-
"default_text_model" => "
|
|
41
|
-
"default_image_model" => "
|
|
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" => "
|
|
45
|
+
"name" => "Qwen/Qwen3.6-35B-A3B",
|
|
46
46
|
"type" => "text"
|
|
47
47
|
},
|
|
48
48
|
{
|
|
49
|
-
"name" => "
|
|
49
|
+
"name" => "Qwen/Qwen3-ASR-1.7B",
|
|
50
50
|
"type" => "audio_transcription"
|
|
51
51
|
},
|
|
52
52
|
{
|
|
53
|
-
"name" => "
|
|
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: "
|
|
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: "
|
|
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": "
|
|
449
|
+
"model": "Qwen/Qwen3.6-35B-A3B"
|
|
384
450
|
}
|
|
385
451
|
```
|
|
386
452
|
|
|
387
|
-
Invoking this request would result in "
|
|
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: "
|
|
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: "
|
|
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(
|
|
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
|
-
|
|
26
|
-
|
|
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
|
-
#
|
|
83
|
-
#
|
|
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
|
|
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
|
|