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 +4 -4
- data/docs/README.md +124 -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 +21 -21
- data/lib/nitro_intelligence/client/handlers/observed/chat_handler.rb +17 -21
- data/lib/nitro_intelligence/client/handlers/observed/image_handler.rb +11 -15
- data/lib/nitro_intelligence/client/handlers/observed/text_to_speech_handler.rb +20 -25
- 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/observability/prompt_resolver.rb +69 -0
- data/lib/nitro_intelligence/version.rb +1 -1
- metadata +2 -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).
|
|
@@ -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": "
|
|
449
|
+
"model": "Qwen/Qwen3.6-35B-A3B"
|
|
341
450
|
}
|
|
342
451
|
```
|
|
343
452
|
|
|
344
|
-
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:
|
|
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: "
|
|
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: "
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
|
|
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
|
-
|
|
55
|
-
|
|
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(
|
|
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
|
-
|
|
24
|
-
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
|
|
38
|
+
return nil if prompt.blank?
|
|
39
39
|
|
|
40
|
-
|
|
41
|
-
parameters[:messages]
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
)
|
|
40
|
+
parameters[:messages] = prompt.interpolate(
|
|
41
|
+
messages: parameters[:messages],
|
|
42
|
+
variables: parameters[:prompt_variables] || {}
|
|
43
|
+
)
|
|
45
44
|
|
|
46
|
-
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
|
|
66
|
+
return nil if prompt.blank?
|
|
69
67
|
|
|
70
|
-
|
|
71
|
-
parameters[:messages]
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
)
|
|
68
|
+
parameters[:messages] = prompt.interpolate(
|
|
69
|
+
messages: parameters[:messages],
|
|
70
|
+
variables: parameters[:prompt_variables] || {}
|
|
71
|
+
)
|
|
75
72
|
|
|
76
|
-
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
#
|
|
87
|
-
#
|
|
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
|
|
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
|
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.
|
|
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
|