nitro_intelligence 0.0.1
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 +7 -0
- data/Rakefile +6 -0
- data/docs/README.md +247 -0
- data/lib/nitro_intelligence/agent_server.rb +76 -0
- data/lib/nitro_intelligence/client.rb +337 -0
- data/lib/nitro_intelligence/configuration.rb +25 -0
- data/lib/nitro_intelligence/langfuse_extension.rb +126 -0
- data/lib/nitro_intelligence/langfuse_tracer_provider.rb +59 -0
- data/lib/nitro_intelligence/media/image.rb +36 -0
- data/lib/nitro_intelligence/media/image_generation.rb +130 -0
- data/lib/nitro_intelligence/media/media.rb +19 -0
- data/lib/nitro_intelligence/media/upload_handler.rb +135 -0
- data/lib/nitro_intelligence/models/model.rb +22 -0
- data/lib/nitro_intelligence/models/model_catalog.rb +17 -0
- data/lib/nitro_intelligence/models/model_factory.rb +19 -0
- data/lib/nitro_intelligence/null_cache.rb +20 -0
- data/lib/nitro_intelligence/prompt/prompt.rb +56 -0
- data/lib/nitro_intelligence/prompt/prompt_store.rb +110 -0
- data/lib/nitro_intelligence/trace.rb +7 -0
- data/lib/nitro_intelligence/version.rb +3 -0
- data/lib/nitro_intelligence.rb +76 -0
- metadata +277 -0
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
# This is an adaptation of https://github.com/simplepractice/langfuse-rb/blob/main/lib/langfuse.rb
|
|
2
|
+
# that lets users set custom trace IDs and create multiple Langfuse clients in the same process.
|
|
3
|
+
#
|
|
4
|
+
# The content of this file should eventually make its way upstream. Setting a custom trace ID is
|
|
5
|
+
# already awaiting approval in https://github.com/simplepractice/langfuse-rb/pull/69.
|
|
6
|
+
#
|
|
7
|
+
module NitroIntelligence
|
|
8
|
+
class LangfuseExtension
|
|
9
|
+
attr_reader :config
|
|
10
|
+
|
|
11
|
+
def initialize
|
|
12
|
+
config = Langfuse::Config.new
|
|
13
|
+
yield(config) if block_given?
|
|
14
|
+
|
|
15
|
+
@config = config
|
|
16
|
+
@client = Langfuse::Client.new(config)
|
|
17
|
+
@tracer_provider = LangfuseTracerProvider.new(config)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def shutdown(timeout: 30)
|
|
21
|
+
@client.shutdown
|
|
22
|
+
@tracer_provider.shutdown(timeout:)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def create_score(name:, value:, id: nil, trace_id: nil, session_id: nil, observation_id: nil, comment: nil, # rubocop:disable Metrics/ParameterLists
|
|
26
|
+
metadata: nil, environment: nil, data_type: :numeric, dataset_run_id: nil, config_id: nil)
|
|
27
|
+
@client.create_score(
|
|
28
|
+
name:,
|
|
29
|
+
value:,
|
|
30
|
+
id:,
|
|
31
|
+
trace_id:,
|
|
32
|
+
session_id:,
|
|
33
|
+
observation_id:,
|
|
34
|
+
comment:,
|
|
35
|
+
metadata:,
|
|
36
|
+
environment:,
|
|
37
|
+
data_type:,
|
|
38
|
+
dataset_run_id:,
|
|
39
|
+
config_id:
|
|
40
|
+
)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def start_observation(name, attrs = {}, as_type: :span, parent_span_context: nil, start_time: nil,
|
|
44
|
+
skip_validation: false)
|
|
45
|
+
type_str = as_type.to_s
|
|
46
|
+
|
|
47
|
+
unless skip_validation || Langfuse.send(:valid_observation_type?, as_type)
|
|
48
|
+
valid_types = Langfuse::OBSERVATION_TYPES.values.sort.join(", ")
|
|
49
|
+
raise ArgumentError, "Invalid observation type: #{type_str}. Valid types: #{valid_types}"
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
otel_tracer = @tracer_provider.tracer
|
|
53
|
+
otel_span = Langfuse.send(:create_otel_span,
|
|
54
|
+
name:,
|
|
55
|
+
start_time:,
|
|
56
|
+
parent_span_context:,
|
|
57
|
+
otel_tracer:)
|
|
58
|
+
|
|
59
|
+
# Serialize attributes
|
|
60
|
+
# Only set attributes if span is still recording (should always be true here, but guard for safety)
|
|
61
|
+
if otel_span.recording?
|
|
62
|
+
otel_attrs = Langfuse::OtelAttributes.create_observation_attributes(type_str, attrs.to_h, mask: nil)
|
|
63
|
+
otel_attrs.each { |key, value| otel_span.set_attribute(key, value) }
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Wrap in appropriate class (attributes already set on span above — pass nil to avoid double-masking)
|
|
67
|
+
observation = Langfuse.send(:wrap_otel_span, otel_span, type_str, otel_tracer)
|
|
68
|
+
|
|
69
|
+
# Events auto-end immediately when created
|
|
70
|
+
observation.end if type_str == Langfuse::OBSERVATION_TYPES[:event]
|
|
71
|
+
|
|
72
|
+
observation
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def observe(name, attrs = {}, as_type: :span, trace_id: nil, **kwargs, &block)
|
|
76
|
+
# Merge positional attrs and keyword kwargs
|
|
77
|
+
merged_attrs = attrs.to_h.merge(kwargs)
|
|
78
|
+
unless trace_id.nil?
|
|
79
|
+
unless valid_trace_id?(trace_id)
|
|
80
|
+
raise ArgumentError, "#{trace_id} is not a valid 32 lowercase hex char Langfuse trace ID"
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
parent_span_context = create_span_context_with_trace_id(trace_id)
|
|
84
|
+
end
|
|
85
|
+
observation = start_observation(name, merged_attrs, as_type:, parent_span_context:)
|
|
86
|
+
|
|
87
|
+
if block
|
|
88
|
+
# Block-based API: auto-ends when block completes
|
|
89
|
+
# Set context and execute block
|
|
90
|
+
current_context = OpenTelemetry::Context.current
|
|
91
|
+
begin
|
|
92
|
+
result = OpenTelemetry::Context.with_current(
|
|
93
|
+
OpenTelemetry::Trace.context_with_span(observation.otel_span, parent_context: current_context)
|
|
94
|
+
) do
|
|
95
|
+
yield(observation)
|
|
96
|
+
end
|
|
97
|
+
ensure
|
|
98
|
+
# Only end if not already ended (events auto-end in start_observation)
|
|
99
|
+
observation.end unless as_type.to_s == Langfuse::OBSERVATION_TYPES[:event]
|
|
100
|
+
end
|
|
101
|
+
result
|
|
102
|
+
else
|
|
103
|
+
# Stateful API - return observation
|
|
104
|
+
# Events already auto-ended in start_observation
|
|
105
|
+
observation
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
private
|
|
110
|
+
|
|
111
|
+
def valid_trace_id?(trace_id)
|
|
112
|
+
!!(trace_id =~ /^[0-9a-f]{32}$/)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def create_span_context_with_trace_id(trace_id_as_hex_str)
|
|
116
|
+
trace_id_as_byte_str = [trace_id_as_hex_str].pack("H*")
|
|
117
|
+
# NOTE: trace_flags must be SAMPLED or the trace will not appear in Langfuse.
|
|
118
|
+
# The Python SDK does the same: https://github.com/langfuse/langfuse-python/blob/v4.0.0/langfuse/_client/client.py#L1568
|
|
119
|
+
trace_flags = OpenTelemetry::Trace::TraceFlags::SAMPLED
|
|
120
|
+
OpenTelemetry::Trace::SpanContext.new(
|
|
121
|
+
trace_id: trace_id_as_byte_str,
|
|
122
|
+
trace_flags:
|
|
123
|
+
)
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# This is an adaptation of https://github.com/simplepractice/langfuse-rb/blob/main/lib/langfuse/otel_setup.rb
|
|
2
|
+
# that lets users create multiple Langfuse clients in the same process.
|
|
3
|
+
# See also components/nitro_intelligence/lib/nitro_intelligence/langfuse_extension.rb
|
|
4
|
+
#
|
|
5
|
+
# The content of this file should eventually make its way upstream.
|
|
6
|
+
#
|
|
7
|
+
module NitroIntelligence
|
|
8
|
+
class LangfuseTracerProvider
|
|
9
|
+
def initialize(config)
|
|
10
|
+
@tracer_provider = create_tracer_provider(config)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def tracer
|
|
14
|
+
@tracer_provider.tracer("langfuse-rb", Langfuse::VERSION)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def shutdown(timeout: 30)
|
|
18
|
+
@tracer_provider.shutdown(timeout:)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
def create_tracer_provider(config)
|
|
24
|
+
raise ArgumentError, "training_async must be true" unless config.tracing_async
|
|
25
|
+
|
|
26
|
+
exporter = OpenTelemetry::Exporter::OTLP::Exporter.new(
|
|
27
|
+
endpoint: "#{config.base_url}/api/public/otel/v1/traces",
|
|
28
|
+
headers: build_headers(config.public_key, config.secret_key),
|
|
29
|
+
compression: "gzip"
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
processor = OpenTelemetry::SDK::Trace::Export::BatchSpanProcessor.new(
|
|
33
|
+
exporter,
|
|
34
|
+
max_queue_size: config.batch_size * 2,
|
|
35
|
+
schedule_delay: config.flush_interval * 1000,
|
|
36
|
+
max_export_batch_size: config.batch_size
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
tracer_provider = OpenTelemetry::SDK::Trace::TracerProvider.new
|
|
40
|
+
tracer_provider.add_span_processor(processor)
|
|
41
|
+
span_processor = Langfuse::SpanProcessor.new(config:)
|
|
42
|
+
tracer_provider.add_span_processor(span_processor)
|
|
43
|
+
|
|
44
|
+
OpenTelemetry.propagation = OpenTelemetry::Trace::Propagation::TraceContext::TextMapPropagator.new
|
|
45
|
+
|
|
46
|
+
config.logger.info("Langfuse tracing initialized with OpenTelemetry (async mode)")
|
|
47
|
+
|
|
48
|
+
tracer_provider
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def build_headers(public_key, secret_key)
|
|
52
|
+
credentials = "#{public_key}:#{secret_key}"
|
|
53
|
+
encoded = Base64.strict_encode64(credentials)
|
|
54
|
+
{
|
|
55
|
+
"Authorization" => "Basic #{encoded}",
|
|
56
|
+
}
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
require "base64"
|
|
2
|
+
require "mini_magick"
|
|
3
|
+
|
|
4
|
+
require "nitro_intelligence/media/media"
|
|
5
|
+
|
|
6
|
+
module NitroIntelligence
|
|
7
|
+
class Image < Media
|
|
8
|
+
attr_reader :height, :width
|
|
9
|
+
|
|
10
|
+
def self.from_base64(base64_string)
|
|
11
|
+
# Strip data_uri from string
|
|
12
|
+
base64_string = base64_string.sub(/^data:[^;]*;base64,/, "")
|
|
13
|
+
byte_string = Base64.strict_decode64(base64_string)
|
|
14
|
+
|
|
15
|
+
new(byte_string)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def initialize(file)
|
|
19
|
+
super
|
|
20
|
+
|
|
21
|
+
image = MiniMagick::Image.read(StringIO.new(file))
|
|
22
|
+
|
|
23
|
+
@mime_type = image.mime_type
|
|
24
|
+
@height = image.height
|
|
25
|
+
@width = image.width
|
|
26
|
+
|
|
27
|
+
parse_mime_type
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def parse_mime_type
|
|
33
|
+
@file_type, @file_extension = @mime_type.split("/", 2)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
require "base64"
|
|
2
|
+
require "digest"
|
|
3
|
+
require "mini_magick"
|
|
4
|
+
require "stringio"
|
|
5
|
+
require "time"
|
|
6
|
+
|
|
7
|
+
require "nitro_intelligence/media/image"
|
|
8
|
+
|
|
9
|
+
module NitroIntelligence
|
|
10
|
+
class ImageGeneration
|
|
11
|
+
class Config
|
|
12
|
+
CUSTOM_PARAMS = %i[aspect_ratio image_generation resolution].freeze
|
|
13
|
+
DEFAULT_ASPECT_RATIO = "1:1".freeze
|
|
14
|
+
DEFAULT_RESOLUTION = "1K".freeze
|
|
15
|
+
|
|
16
|
+
attr_accessor :aspect_ratio, :model, :resolution
|
|
17
|
+
|
|
18
|
+
def initialize
|
|
19
|
+
@aspect_ratio = DEFAULT_ASPECT_RATIO
|
|
20
|
+
@model = NitroIntelligence.model_catalog.default_image_model.name
|
|
21
|
+
@resolution = DEFAULT_RESOLUTION
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
attr_reader :byte_string, :config, :file_type, :file_extension, :generated_image, :messages, :reference_images,
|
|
26
|
+
:target_image
|
|
27
|
+
attr_accessor :trace_id
|
|
28
|
+
|
|
29
|
+
def initialize(
|
|
30
|
+
message: "",
|
|
31
|
+
target_image: nil,
|
|
32
|
+
reference_images: []
|
|
33
|
+
)
|
|
34
|
+
@config = Config.new
|
|
35
|
+
@generated_image = nil
|
|
36
|
+
@model = NitroIntelligence.model_catalog.lookup_by_name(@config.model)
|
|
37
|
+
@reference_images = reference_images.map { |img| Image.new(img) }
|
|
38
|
+
@trace_id = nil
|
|
39
|
+
|
|
40
|
+
# Overrides
|
|
41
|
+
yield(@config) if block_given?
|
|
42
|
+
validate_config!
|
|
43
|
+
|
|
44
|
+
if target_image
|
|
45
|
+
@target_image = Image.new(target_image)
|
|
46
|
+
configure_aspect_ratio
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
build_messages(message, @target_image, @reference_images)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def files
|
|
53
|
+
[target_image, reference_images, generated_image].flatten.compact
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def parse_file(chat_completion)
|
|
57
|
+
base64_string = chat_completion.choices.first&.message.to_h.fetch(:images, {})&.first&.dig(:image_url, :url)
|
|
58
|
+
|
|
59
|
+
return unless base64_string
|
|
60
|
+
|
|
61
|
+
@generated_image = Image.from_base64(base64_string)
|
|
62
|
+
@generated_image.direction = "output"
|
|
63
|
+
@generated_image
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
private
|
|
67
|
+
|
|
68
|
+
def build_messages(message, target_image, reference_images)
|
|
69
|
+
messages = [{ role: "user", content: [] }]
|
|
70
|
+
|
|
71
|
+
messages.first[:content].append({ type: "text", text: message }) if message.present?
|
|
72
|
+
|
|
73
|
+
if target_image
|
|
74
|
+
messages.first[:content].append(image_message(mime_type: target_image.mime_type, base64: target_image.base64))
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
reference_images.each do |img|
|
|
78
|
+
messages.first[:content].append(image_message(mime_type: img.mime_type, base64: img.base64))
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
@messages = messages
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def calculate_aspect_ratios
|
|
85
|
+
@model.aspect_ratios.index_by { |x| x.split(":").map(&:to_f).reduce(:/) }
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def closest_aspect_ratio(width, height)
|
|
89
|
+
actual_ratio = width.to_f / height
|
|
90
|
+
calculated_aspect_ratios = calculate_aspect_ratios
|
|
91
|
+
best_match = calculated_aspect_ratios.keys.min_by { |ratio_val| (ratio_val - actual_ratio).abs }
|
|
92
|
+
calculated_aspect_ratios[best_match]
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def configure_aspect_ratio
|
|
96
|
+
@config.aspect_ratio = closest_aspect_ratio(@target_image.width, @target_image.height)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def image_message(url: nil, mime_type: nil, base64: nil)
|
|
100
|
+
url = "data:#{mime_type};base64,#{base64}" if url.nil?
|
|
101
|
+
|
|
102
|
+
{
|
|
103
|
+
type: "image_url",
|
|
104
|
+
image_url: {
|
|
105
|
+
url:,
|
|
106
|
+
},
|
|
107
|
+
}
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def validate_config!
|
|
111
|
+
# Check model supported
|
|
112
|
+
@model = NitroIntelligence.model_catalog.lookup_by_name(@config.model)
|
|
113
|
+
raise ArgumentError, "Unsupported model: '#{@config.model}'" unless @model
|
|
114
|
+
|
|
115
|
+
# Check aspect_ratio supported
|
|
116
|
+
unless @model.aspect_ratios.include?(@config.aspect_ratio)
|
|
117
|
+
raise ArgumentError,
|
|
118
|
+
"Unsupported aspect ratio: '#{@config.aspect_ratio}'. " \
|
|
119
|
+
"Supported ratios for #{@config.model} are: #{@model.aspect_ratios}"
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Check resolution supported
|
|
123
|
+
return if @model.resolutions.include?(@config.resolution)
|
|
124
|
+
|
|
125
|
+
raise ArgumentError,
|
|
126
|
+
"Unsupported resolution: '#{@config.resolution}'. " \
|
|
127
|
+
"Supported resolutions for #{@config.model} are: #{@model.resolutions}"
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
require "base64"
|
|
2
|
+
|
|
3
|
+
module NitroIntelligence
|
|
4
|
+
class Media
|
|
5
|
+
attr_accessor :direction, :reference_id
|
|
6
|
+
attr_reader :base64, :byte_string, :file_extension, :file_type, :mime_type
|
|
7
|
+
|
|
8
|
+
# Input should be byte string. e.g. File.binread('file.ext')
|
|
9
|
+
def initialize(file)
|
|
10
|
+
@base64 = Base64.strict_encode64(file)
|
|
11
|
+
@byte_string = file
|
|
12
|
+
@direction = "input"
|
|
13
|
+
@file_extension = nil
|
|
14
|
+
@file_type = nil
|
|
15
|
+
@mime_type = nil
|
|
16
|
+
@reference_id = nil
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
require "base64"
|
|
2
|
+
require "digest"
|
|
3
|
+
require "httparty"
|
|
4
|
+
require "time"
|
|
5
|
+
|
|
6
|
+
module NitroIntelligence
|
|
7
|
+
class UploadHandler
|
|
8
|
+
def initialize(observability_host, observability_auth_token)
|
|
9
|
+
@observability_host = observability_host
|
|
10
|
+
@observability_auth_token = observability_auth_token
|
|
11
|
+
@uploaded_media = []
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def replace_base64_with_media_references(payload)
|
|
15
|
+
# Make it easier to lookup message style image_urls
|
|
16
|
+
media_lookup = @uploaded_media.index_by { |image| "data:#{image.mime_type};base64,#{image.base64}" }
|
|
17
|
+
|
|
18
|
+
replace_base64_image_url = ->(node) do
|
|
19
|
+
case node
|
|
20
|
+
when Hash
|
|
21
|
+
url_key = if node.key?(:url)
|
|
22
|
+
:url
|
|
23
|
+
else
|
|
24
|
+
(node.key?("url") ? "url" : nil)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
if url_key
|
|
28
|
+
url_value = node[url_key]
|
|
29
|
+
|
|
30
|
+
# Replace base64 strings if they match with our uploaded media
|
|
31
|
+
if (media = media_lookup[url_value])
|
|
32
|
+
# Overwrite base64 string with Langfuse media ref
|
|
33
|
+
# This *should* be rendering inline in the gui
|
|
34
|
+
# https://github.com/langfuse/langfuse/issues/4555
|
|
35
|
+
# https://github.com/langfuse/langfuse/issues/5030
|
|
36
|
+
node[url_key] = "@@@langfuseMedia:type=#{media.mime_type}|id=#{media.reference_id}|source=bytes@@@"
|
|
37
|
+
# Sometimes models can generate unwanted images that will not have a reference
|
|
38
|
+
# these untracked base64 strings can easily push 4.5mb Langfuse limit
|
|
39
|
+
elsif url_value.is_a?(String) && url_value.start_with?("data:")
|
|
40
|
+
node[url_key] = "[Discarded media]"
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
node.each_value { |val| replace_base64_image_url.call(val) }
|
|
45
|
+
|
|
46
|
+
when Array
|
|
47
|
+
node.each { |val| replace_base64_image_url.call(val) }
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
replace_base64_image_url.call(payload)
|
|
52
|
+
|
|
53
|
+
payload
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def upload(trace_id, upload_queue: Queue.new)
|
|
57
|
+
until upload_queue.empty?
|
|
58
|
+
media = upload_queue.pop
|
|
59
|
+
|
|
60
|
+
content_length = media.byte_string.bytesize
|
|
61
|
+
content_sha256 = Base64.strict_encode64(Digest::SHA256.digest(media.byte_string))
|
|
62
|
+
|
|
63
|
+
# returns {"mediaId" -> "", "uploadUrl" => ""}
|
|
64
|
+
upload_url_response = get_upload_url({
|
|
65
|
+
traceId: trace_id,
|
|
66
|
+
contentType: media.mime_type,
|
|
67
|
+
contentLength: content_length,
|
|
68
|
+
sha256Hash: content_sha256,
|
|
69
|
+
field: media.direction,
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
# NOTE: uploadUrl is None if the file is stored in Langfuse already as then there is no need to upload it again.
|
|
73
|
+
upload_response = upload_media(
|
|
74
|
+
upload_url_response["mediaId"],
|
|
75
|
+
upload_url_response["uploadUrl"],
|
|
76
|
+
media.mime_type,
|
|
77
|
+
content_sha256,
|
|
78
|
+
media.byte_string
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
associate_media(upload_url_response["mediaId"], upload_response) if upload_response.present?
|
|
82
|
+
|
|
83
|
+
media.reference_id = upload_url_response["mediaId"]
|
|
84
|
+
@uploaded_media.append(media)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
@uploaded_media
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
private
|
|
91
|
+
|
|
92
|
+
def get_upload_url(request_body)
|
|
93
|
+
HTTParty.post(
|
|
94
|
+
"#{@observability_host}/api/public/media",
|
|
95
|
+
body: request_body.to_json,
|
|
96
|
+
headers: {
|
|
97
|
+
"Content-Type" => "application/json",
|
|
98
|
+
"Authorization" => "Basic #{@observability_auth_token}",
|
|
99
|
+
}
|
|
100
|
+
)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def associate_media(media_id, upload_response)
|
|
104
|
+
request_body = {
|
|
105
|
+
uploadedAt: Time.now.utc.iso8601(6),
|
|
106
|
+
uploadHttpStatus: upload_response.code,
|
|
107
|
+
uploadHttpError: upload_response.code == 200 ? nil : upload_response.body,
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
HTTParty.patch(
|
|
111
|
+
"#{@observability_host}/api/public/media/#{media_id}",
|
|
112
|
+
body: request_body.to_json,
|
|
113
|
+
headers: {
|
|
114
|
+
"Content-Type" => "application/json",
|
|
115
|
+
"Authorization" => "Basic #{@observability_auth_token}",
|
|
116
|
+
}
|
|
117
|
+
)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def upload_media(media_id, upload_url, content_type, content_sha256, content_bytes)
|
|
121
|
+
if media_id.present? && upload_url.present?
|
|
122
|
+
return HTTParty.put(
|
|
123
|
+
upload_url,
|
|
124
|
+
headers: {
|
|
125
|
+
"Content-Type" => content_type,
|
|
126
|
+
"x-amz-checksum-sha256" => content_sha256,
|
|
127
|
+
},
|
|
128
|
+
body: content_bytes
|
|
129
|
+
)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
nil
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
module NitroIntelligence
|
|
2
|
+
class Model
|
|
3
|
+
attr_reader :name, :omit_output_fields
|
|
4
|
+
|
|
5
|
+
def initialize(name:, omit_output_fields: [], **)
|
|
6
|
+
@name = name
|
|
7
|
+
@omit_output_fields = omit_output_fields.map { |field| field.split(".").map(&:to_sym) }
|
|
8
|
+
end
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
class TextModel < Model; end
|
|
12
|
+
|
|
13
|
+
class ImageModel < Model
|
|
14
|
+
attr_reader :aspect_ratios, :resolutions
|
|
15
|
+
|
|
16
|
+
def initialize(name:, omit_output_fields: [], aspect_ratios: [], resolutions: [], **)
|
|
17
|
+
super
|
|
18
|
+
@aspect_ratios = aspect_ratios
|
|
19
|
+
@resolutions = resolutions
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
require "nitro_intelligence/models/model_factory"
|
|
2
|
+
|
|
3
|
+
module NitroIntelligence
|
|
4
|
+
class ModelCatalog
|
|
5
|
+
attr_reader :models, :default_text_model, :default_image_model
|
|
6
|
+
|
|
7
|
+
def initialize(model_config)
|
|
8
|
+
@models = (model_config[:models] || []).map { |model_metadata| ModelFactory.build(model_metadata) }
|
|
9
|
+
@default_text_model = lookup_by_name(model_config[:default_text_model])
|
|
10
|
+
@default_image_model = lookup_by_name(model_config[:default_image_model])
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def lookup_by_name(name)
|
|
14
|
+
@models.find { |model| model.name == name }
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
require "nitro_intelligence/models/model"
|
|
2
|
+
|
|
3
|
+
module NitroIntelligence
|
|
4
|
+
class ModelFactory
|
|
5
|
+
def self.build(model_metadata)
|
|
6
|
+
model_metadata = model_metadata.symbolize_keys
|
|
7
|
+
|
|
8
|
+
if image_model?(model_metadata)
|
|
9
|
+
ImageModel.new(**model_metadata)
|
|
10
|
+
else
|
|
11
|
+
TextModel.new(**model_metadata)
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def self.image_model?(model_metadata)
|
|
16
|
+
model_metadata.key?(:aspect_ratios) || model_metadata.key?(:resolutions)
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
module NitroIntelligence
|
|
2
|
+
# Default no-operation cache implementation used
|
|
3
|
+
# as a sensible default for initializing NitroIntelligence
|
|
4
|
+
class NullCache
|
|
5
|
+
def read(_key) = nil
|
|
6
|
+
def write(_key, _value, **_options) = true
|
|
7
|
+
def delete(_key) = true
|
|
8
|
+
|
|
9
|
+
def fetch(key, **)
|
|
10
|
+
value = read(key)
|
|
11
|
+
return value unless value.nil?
|
|
12
|
+
|
|
13
|
+
return unless block_given?
|
|
14
|
+
|
|
15
|
+
computed = yield
|
|
16
|
+
write(key, computed, **)
|
|
17
|
+
computed
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
module NitroIntelligence
|
|
2
|
+
class Prompt
|
|
3
|
+
attr_reader :name, :type, :prompt, :version, :config, :labels, :tags
|
|
4
|
+
|
|
5
|
+
VARIABLE_REGEX = /\{\{([a-zA-Z0-9_]+)\}\}/
|
|
6
|
+
|
|
7
|
+
def initialize(name:, type:, prompt:, version:, **extra_args)
|
|
8
|
+
@name = name
|
|
9
|
+
@type = type
|
|
10
|
+
@prompt = prompt
|
|
11
|
+
@version = version
|
|
12
|
+
@config = extra_args[:config] || {}
|
|
13
|
+
@labels = extra_args[:labels] || []
|
|
14
|
+
@tags = extra_args[:tags] || []
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Returns prompt "content" from API with prompt variables replaced
|
|
18
|
+
# Prompt "content" will either be a string or an array of hashes
|
|
19
|
+
# based on prompt "type" ("text" or "chat")
|
|
20
|
+
def compile(**replacements)
|
|
21
|
+
return replace_variables(@prompt, **replacements) if @type == "text"
|
|
22
|
+
|
|
23
|
+
@prompt.map do |message|
|
|
24
|
+
message[:content] = replace_variables(message[:content], **replacements)
|
|
25
|
+
message
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Takes provided chat messages and inserts the compiled prompt
|
|
30
|
+
# into the correct position based on prompt "type" ("text" or "chat")
|
|
31
|
+
def interpolate(messages:, variables:)
|
|
32
|
+
if @type == "text"
|
|
33
|
+
messages.prepend({ role: "system", content: compile(**variables) })
|
|
34
|
+
elsif @type == "chat"
|
|
35
|
+
compile(**variables) + messages
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def variables
|
|
40
|
+
messages = @type == "text" ? [@prompt] : @prompt.pluck(:content)
|
|
41
|
+
|
|
42
|
+
messages.map do |message|
|
|
43
|
+
message.scan(VARIABLE_REGEX).flatten.map(&:to_sym)
|
|
44
|
+
end.flatten
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
def replace_variables(input, **replacements)
|
|
50
|
+
input.gsub(VARIABLE_REGEX) do |match|
|
|
51
|
+
key = ::Regexp.last_match(1).to_sym
|
|
52
|
+
replacements.fetch(key, match)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|