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.
@@ -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