nitro_intelligence 1.0.1 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b4de13e4abd28f0e4ce7533a094fd184c18f7804a116e8812c8c6ecbd5036521
4
- data.tar.gz: b98ecfd8fd43f901bf8a4fd4ee36a41c3740a0c43e7e674a0fc9792cec6b1097
3
+ metadata.gz: 7d1c0e6c9ddffc032a989ebffb001d58aefabf86e50f92966a6d384a5218628f
4
+ data.tar.gz: 60b9ae5ef4fd8c7a79bc45f13a1b965c9d9a0792f38bc285e361822b7d91a8ad
5
5
  SHA512:
6
- metadata.gz: eaa80ace33f1bbdd3c94ef6ea7c7d718903f4cbe1e2559a714be34a6207ddd6c7ebd34a4874f0545d583a76df6af9975b4b36c46ce6eddcfd26a85482c51907c
7
- data.tar.gz: 9b404c2f250b6ba80f46ec957faa31895107b81d2011c5f2ad35e3a1cc6845efe8510bd4e5760b8313d5e30dbf2b74a030d3ea09482af9c76fc193fe64b28e95
6
+ metadata.gz: a13a9b1a0a58f58c142ab87ec89f491f409341717b3101b9bb1579c88b8ed9335413ea73d621c489749b7d151d62672e9277e3d39da80c4721008cad28a301b0
7
+ data.tar.gz: 1c498a0a4ec99af32f039dd43272ad61650da433c4f4d3b4297aadd473fba4c93af8d9ed08db0d2faf5012ed3a721e6d47587bf5429eb16368161d224f773cf7
data/docs/README.md CHANGED
@@ -33,6 +33,39 @@ NitroIntelligence.configure do |config|
33
33
 
34
34
  # Agent server settings (optional)
35
35
  config.agent_server_config = {} # Hash of AgentServer keyword arguments
36
+
37
+ # Model configuration
38
+ config.model_config = {
39
+ "default_audio_transcription_model" => "gpt-4o-transcribe",
40
+ "default_text_model" => "gpt-4o-mini",
41
+ "default_image_model" => "nano-banana-2",
42
+ "default_text_to_speech_model" => "gpt-4o-mini-tts",
43
+ "models" => [
44
+ {
45
+ "name" => "gpt-4o-mini",
46
+ "type" => "text"
47
+ },
48
+ {
49
+ "name" => "gpt-4o-transcribe",
50
+ "type" => "audio_transcription"
51
+ },
52
+ {
53
+ "name" => "nano-banana-2",
54
+ "type" => "image",
55
+ "aspect_ratios" => ["1:1", "2:3", "3:2", "3:4", "4:3"],
56
+ "resolutions" => ["512", "1K", "2K"],
57
+ "omit_output_fields" => ["provider_specific_fields.thought_signatures"]
58
+ },
59
+ {
60
+ "name" => "gpt-4o-mini-tts",
61
+ "type" => "text_to_speech",
62
+ "default_voice" => "marin",
63
+ "default_response_format" => "mp3",
64
+ "voices" => ["echo", "nova", "marin", "cedar"],
65
+ "response_formats" => ["mp3", "wav"]
66
+ }
67
+ ]
68
+ }
36
69
  end
37
70
  ```
38
71
 
@@ -48,6 +81,7 @@ end
48
81
  | `observability_base_url` | `String` | `""` | Base URL for the Langfuse observability service |
49
82
  | `observability_projects` | `Array<Hash>` | `[]` | Langfuse project credentials (slug, id, public_key, secret_key) |
50
83
  | `agent_server_config` | `Hash` | `{}` | Credentials for `AgentServer.new`. Expected keys: `base_url` (String) — HTTP base URL of the agent server; `api_key` (String) — bearer token; `user_id` (String, default: `"default-user"`) — caller identity |
84
+ | `model_config` | `Hash` | `{}` | Model defaults and per-model settings. Top-level keys: `default_text_model`, `default_audio_transcription_model`, `default_image_model`, `default_text_to_speech_model`, and `models` (array of per-model hashes keyed by `name` and `type`, with type-specific options like `aspect_ratios`/`resolutions` for images or `voices`/`response_formats` for TTS) |
51
85
 
52
86
  ## Basic Usage
53
87
 
@@ -111,6 +145,40 @@ puts result
111
145
  # <OpenAI::Models::Audio::Transcription:0x2fd8c {:text=>"Hola, ¿cómo estás hoy?", :usage=>{:input_tokens=>37, :output_tokens=>9, :total_tokens=>46, :type=>:tokens, :input_token_details=>{:audio_tokens=>28, :text_tokens=>9}}}>
112
146
  ```
113
147
 
148
+ ### Text-to-Speech
149
+
150
+ Nitro Intelligence can be used to create spoken-word audio files from text.
151
+
152
+ Basic example of usage:
153
+
154
+ ```ruby
155
+ client = NitroIntelligence::Client.new
156
+ tts = client.text_to_speech(message: 'Hello, this is Power Home Remodeling Group.')
157
+
158
+ # tts is a StringIO object, we can write the file to disk
159
+ File.binwrite('tts.mp3', tts.string)
160
+ ```
161
+
162
+ Supplying custom parameters:
163
+
164
+ ```ruby
165
+ client = NitroIntelligence::Client.new
166
+ tts = client.text_to_speech(
167
+ message: 'Hello, this is Power Home Remodeling Group.',
168
+ parameters: {
169
+ voice: 'marin',
170
+ response_format: 'mp3',
171
+ instructions: 'Translate the English into Spanish before speaking.',
172
+ speed: 1.25
173
+ }
174
+ )
175
+
176
+ # tts is a StringIO object, we can write the file to disk
177
+ File.binwrite('tts.mp3', tts.string)
178
+ ```
179
+
180
+ For a full list of supported parameters, see the [API reference here](https://developers.openai.com/api/reference/resources/audio/subresources/speech/methods/create ). Note that `voice` and `response_format` are further constrained to the `voices` and `response_formats` listed for the chosen model in `config.model_config`.
181
+
114
182
  ### Image Editing and Generation
115
183
 
116
184
  Nitro Intelligence can be used for image editing and generation
@@ -1,3 +1,6 @@
1
+ require "json"
2
+ require "net/http"
3
+ require "uri"
1
4
  require "nitro_intelligence/tool_call_review_validator"
2
5
 
3
6
  module NitroIntelligence
@@ -7,6 +10,9 @@ module NitroIntelligence
7
10
  class RunError < StandardError; end
8
11
  class ThreadResumptionError < StandardError; end
9
12
 
13
+ # Aegra answers with a conflict when `ifExists: "raise"` is sent for a thread that already exists.
14
+ THREAD_CONFLICT_CODE = 409
15
+
10
16
  attr_reader :base_url, :user_id
11
17
 
12
18
  def initialize(base_url:, api_key:, user_id: "default-user")
@@ -18,6 +24,7 @@ module NitroIntelligence
18
24
  @api_key = api_key
19
25
  @user_id = user_id
20
26
  @tool_call_review_validator = ToolCallReviewValidator.new
27
+ @graph_ids = {}
21
28
  end
22
29
 
23
30
  def await_run(thread_id:, assistant_id:, messages:, context: {})
@@ -26,7 +33,7 @@ module NitroIntelligence
26
33
  initial_state = messages[0..-2]
27
34
  last_message = messages.last
28
35
 
29
- initialize_thread_if_needed(thread_id:, initial_state:)
36
+ initialize_thread_if_needed(thread_id:, assistant_id:, initial_state:)
30
37
  trigger_run(thread_id:, assistant_id:, context:, last_message:)
31
38
  end
32
39
 
@@ -74,36 +81,94 @@ module NitroIntelligence
74
81
 
75
82
  private
76
83
 
77
- def initialize_thread_if_needed(thread_id:, initial_state:)
78
- thread_response = post(
84
+ # Aegra accepts `initial_state` on thread creation but never applies it, so a brand new thread is
85
+ # seeded through the thread state endpoint instead. A thread that already exists is left untouched:
86
+ # its state was seeded when it was created and has been built up by every run since.
87
+ def initialize_thread_if_needed(thread_id:, assistant_id:, initial_state:)
88
+ thread_response = create_thread(thread_id:, assistant_id:)
89
+
90
+ return if thread_already_exists?(thread_response)
91
+ raise ThreadInitializationError, thread_response.body if thread_response.code.to_i != 200
92
+ return if initial_state.blank?
93
+
94
+ seed_new_thread_state(thread_id:, initial_state:)
95
+ end
96
+
97
+ # Creating the thread and seeding its state are separate requests, so a failure between them leaves
98
+ # an empty thread behind. A retry would find that thread, take it for one already under way, skip
99
+ # seeding and run without the history -- losing the very thing seeding exists for, without an error.
100
+ # Discard the thread instead, so a retry starts over from a clean slate.
101
+ def seed_new_thread_state(thread_id:, initial_state:)
102
+ seed_thread_state(thread_id:, initial_state:)
103
+ rescue
104
+ discard_thread(thread_id:)
105
+ raise
106
+ end
107
+
108
+ def discard_thread(thread_id:)
109
+ delete(path: "/threads/#{thread_id}")
110
+ rescue
111
+ # Best effort. The seeding failure is the one worth surfacing, and it is raised either way.
112
+ end
113
+
114
+ def create_thread(thread_id:, assistant_id:)
115
+ post(
79
116
  path: "/threads",
80
117
  body: {
81
118
  threadId: thread_id.to_s,
82
- ifExists: "do_nothing",
83
- initial_state: { messages: initial_state },
119
+ ifExists: "raise",
120
+ # Without a graph_id, the thread state cannot be updated before the thread's first run.
121
+ metadata: { graph_id: graph_id_for(assistant_id) },
84
122
  user_id:,
85
123
  }
86
124
  )
125
+ end
126
+
127
+ def graph_id_for(assistant_id)
128
+ @graph_ids[assistant_id] ||= fetch_graph_id(assistant_id)
129
+ end
130
+
131
+ def fetch_graph_id(assistant_id)
132
+ assistant_response = get(path: "/assistants/#{assistant_id}")
133
+
134
+ raise ThreadInitializationError, assistant_response.body if assistant_response.code.to_i != 200
135
+
136
+ graph_id = JSON.parse(assistant_response.body)["graph_id"]
137
+
138
+ raise ThreadInitializationError, "Assistant #{assistant_id} has no graph_id" if graph_id.blank?
139
+
140
+ graph_id
141
+ end
142
+
143
+ def seed_thread_state(thread_id:, initial_state:)
144
+ state_response = post(
145
+ path: "/threads/#{thread_id}/state",
146
+ body: { values: { messages: initial_state } }
147
+ )
87
148
 
88
- raise ThreadInitializationError, thread_response.body if thread_response.code != 200
149
+ raise ThreadInitializationError, state_response.body if state_response.code.to_i != 200
89
150
 
90
- thread_response
151
+ JSON.parse(state_response.body)
152
+ end
153
+
154
+ def thread_already_exists?(response)
155
+ response.code.to_i == THREAD_CONFLICT_CODE
91
156
  end
92
157
 
93
158
  def get_thread_state(thread_id:)
94
159
  state_response = get(path: "/threads/#{thread_id}/state")
95
160
 
96
- raise ThreadResumptionError, state_response.body if state_response.code != 200
161
+ raise ThreadResumptionError, state_response.body if state_response.code.to_i != 200
97
162
 
98
- state_response
163
+ JSON.parse(state_response.body)
99
164
  end
100
165
 
101
166
  def get_thread(thread_id:)
102
167
  thread_response = get(path: "/threads/#{thread_id}")
103
168
 
104
- raise ThreadResumptionError, thread_response.body if thread_response.code != 200
169
+ raise ThreadResumptionError, thread_response.body if thread_response.code.to_i != 200
105
170
 
106
- thread_response
171
+ JSON.parse(thread_response.body)
107
172
  end
108
173
 
109
174
  def trigger_run(thread_id:, assistant_id:, last_message:, context: {})
@@ -118,9 +183,10 @@ module NitroIntelligence
118
183
  }
119
184
  )
120
185
 
121
- raise RunError, run_response.body if run_response.code != 200
186
+ raise RunError, run_response.body if run_response.code.to_i != 200
122
187
 
123
- Array(run_response["messages"]).last&.dig("content")
188
+ run = JSON.parse(run_response.body)
189
+ Array(run["messages"]).last&.dig("content")
124
190
  end
125
191
 
126
192
  def resume_run(thread_id:, assistant_id:, resume:, context:)
@@ -135,9 +201,9 @@ module NitroIntelligence
135
201
  }
136
202
  )
137
203
 
138
- raise ThreadResumptionError, run_response.body if run_response.code != 200
204
+ raise ThreadResumptionError, run_response.body if run_response.code.to_i != 200
139
205
 
140
- run_response
206
+ JSON.parse(run_response.body)
141
207
  end
142
208
 
143
209
  def interrupted?(thread)
@@ -163,18 +229,37 @@ module NitroIntelligence
163
229
  end
164
230
 
165
231
  def get(path:)
166
- HTTParty.get(
167
- "#{base_url}#{path}",
168
- headers: request_headers
169
- )
232
+ uri = URI("#{base_url}#{path}")
233
+ http = Net::HTTP.new(uri.host, uri.port)
234
+ http.use_ssl = uri.scheme == "https"
235
+
236
+ request = Net::HTTP::Get.new(uri)
237
+ request_headers.each { |k, v| request[k] = v }
238
+
239
+ http.request(request)
240
+ end
241
+
242
+ def delete(path:)
243
+ uri = URI("#{base_url}#{path}")
244
+ http = Net::HTTP.new(uri.host, uri.port)
245
+ http.use_ssl = uri.scheme == "https"
246
+
247
+ request = Net::HTTP::Delete.new(uri)
248
+ request_headers.each { |k, v| request[k] = v }
249
+
250
+ http.request(request)
170
251
  end
171
252
 
172
253
  def post(path:, body:)
173
- HTTParty.post(
174
- "#{base_url}#{path}",
175
- headers: request_headers,
176
- body: body.to_json
177
- )
254
+ uri = URI("#{base_url}#{path}")
255
+ http = Net::HTTP.new(uri.host, uri.port)
256
+ http.use_ssl = uri.scheme == "https"
257
+
258
+ request = Net::HTTP::Post.new(uri)
259
+ request_headers.each { |k, v| request[k] = v }
260
+ request.body = body.to_json
261
+
262
+ http.request(request)
178
263
  end
179
264
 
180
265
  def request_headers
@@ -1,6 +1,7 @@
1
1
  require "nitro_intelligence/client/handlers/audio_transcription_handler"
2
2
  require "nitro_intelligence/client/handlers/chat_handler"
3
3
  require "nitro_intelligence/client/handlers/image_handler"
4
+ require "nitro_intelligence/client/handlers/text_to_speech_handler"
4
5
 
5
6
  module NitroIntelligence
6
7
  module Client
@@ -26,6 +27,11 @@ module NitroIntelligence
26
27
  audio_transcription_handler.create(message:, audio_file:, parameters:)
27
28
  end
28
29
 
30
+ # Returns StringIO
31
+ def text_to_speech(message: "", parameters: {})
32
+ text_to_speech_handler.create(message:, parameters:)
33
+ end
34
+
29
35
  private
30
36
 
31
37
  def audio_transcription_handler
@@ -47,6 +53,10 @@ module NitroIntelligence
47
53
  def respond_to_missing?(method_name, include_private = false)
48
54
  @client.respond_to?(method_name, include_private) || super
49
55
  end
56
+
57
+ def text_to_speech_handler
58
+ @text_to_speech_handler ||= Handlers::TextToSpeechHandler.new(client: @client)
59
+ end
50
60
  end
51
61
  end
52
62
  end
@@ -1,21 +1,19 @@
1
1
  require "openai"
2
+ require "nitro_intelligence/client/handlers/base_handler"
2
3
 
3
4
  module NitroIntelligence
4
5
  module Client
5
6
  module Handlers
6
- class AudioTranscriptionHandler
7
+ class AudioTranscriptionHandler < BaseHandler
7
8
  ALLOWED_EXTRA_PARAMETERS = OpenAI::Models::Audio::TranscriptionCreateParams.fields.keys.uniq.freeze
8
9
 
9
- def initialize(client:)
10
- @client = client
11
- end
12
-
13
10
  def create(audio_file:, message: "", parameters: {})
14
11
  validate_and_resolve!(parameters)
15
12
  perform_request(audio_file:, message:, parameters:)
16
13
  end
17
14
 
18
15
  def perform_request(audio_file:, message: "", parameters: {})
16
+ add_request_headers(parameters, MODALITY_HEADER => "audio", REQUESTED_MODEL_HEADER => parameters[:model])
19
17
  @client.audio.transcriptions.create(
20
18
  prompt: message,
21
19
  file: audio_file,
@@ -0,0 +1,22 @@
1
+ module NitroIntelligence
2
+ module Client
3
+ module Handlers
4
+ class BaseHandler
5
+ MODALITY_HEADER = "nip-modality".freeze
6
+ REQUESTED_MODEL_HEADER = "nip-requested-model".freeze
7
+
8
+ def initialize(client:)
9
+ @client = client
10
+ end
11
+
12
+ private
13
+
14
+ def add_request_headers(parameters, headers)
15
+ request_options = (parameters[:request_options] ||= {})
16
+ (request_options[:extra_headers] ||= {}).merge!(headers.compact)
17
+ parameters
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
@@ -1,21 +1,19 @@
1
1
  require "openai"
2
+ require "nitro_intelligence/client/handlers/base_handler"
2
3
 
3
4
  module NitroIntelligence
4
5
  module Client
5
6
  module Handlers
6
- class ChatHandler
7
+ class ChatHandler < BaseHandler
7
8
  ALLOWED_EXTRA_PARAMETERS = OpenAI::Models::Chat::CompletionCreateParams.fields.keys.uniq.freeze
8
9
 
9
- def initialize(client:)
10
- @client = client
11
- end
12
-
13
10
  def create(message: "", parameters: {})
14
11
  validate_and_resolve!(parameters, message)
15
12
  perform_request(parameters:)
16
13
  end
17
14
 
18
15
  def perform_request(parameters: {})
16
+ add_request_headers(parameters, REQUESTED_MODEL_HEADER => parameters[:model])
19
17
  @client.chat.completions.create(**parameters.slice(*ALLOWED_EXTRA_PARAMETERS))
20
18
  end
21
19
 
@@ -29,7 +27,6 @@ module NitroIntelligence
29
27
  metadata: {},
30
28
  messages: [],
31
29
  model: NitroIntelligence.model_catalog.default_text_model&.name,
32
- extra_headers: { "Prefer" => "wait" },
33
30
  }
34
31
 
35
32
  parameters.replace(default_parameters.merge(parameters))
@@ -1,16 +1,13 @@
1
1
  require "openai"
2
+ require "nitro_intelligence/client/handlers/base_handler"
2
3
  require "nitro_intelligence/media/image_generation"
3
4
 
4
5
  module NitroIntelligence
5
6
  module Client
6
7
  module Handlers
7
- class ImageHandler
8
+ class ImageHandler < BaseHandler
8
9
  ALLOWED_EXTRA_PARAMETERS = OpenAI::Models::Chat::CompletionCreateParams.fields.keys.uniq.freeze
9
10
 
10
- def initialize(client:)
11
- @client = client
12
- end
13
-
14
11
  def create(message: "", target_image: nil, reference_images: [], parameters: {})
15
12
  image_generation = build_image_generation(message:, target_image:, reference_images:, parameters:)
16
13
 
@@ -23,6 +20,7 @@ module NitroIntelligence
23
20
  end
24
21
 
25
22
  def perform_request(parameters: {})
23
+ add_request_headers(parameters, MODALITY_HEADER => "image", REQUESTED_MODEL_HEADER => parameters[:model])
26
24
  @client.chat.completions.create(**parameters.slice(*ALLOWED_EXTRA_PARAMETERS))
27
25
  end
28
26
 
@@ -32,7 +30,6 @@ module NitroIntelligence
32
30
  metadata: {},
33
31
  messages: image_generation.messages,
34
32
  model: image_generation.config.model,
35
- extra_headers: { "Prefer" => "wait" },
36
33
  request_options: {
37
34
  extra_body: {
38
35
  image_config: {
@@ -0,0 +1,100 @@
1
+ require "nitro_intelligence/media/audio"
2
+
3
+ module NitroIntelligence
4
+ module Client
5
+ module Handlers
6
+ module Observed
7
+ class TextToSpeechHandler
8
+ class ObservedTextToSpeechPromptError < StandardError; end
9
+
10
+ def initialize(base_handler:, observer:)
11
+ @base_handler = base_handler
12
+ @observer = observer
13
+ end
14
+
15
+ def create(message: "", parameters: {})
16
+ prompt = handle_prompt(parameters:)
17
+
18
+ @base_handler.validate_and_resolve!(parameters)
19
+
20
+ trace_name = parameters[:trace_name] || prompt&.name || @observer.project_client.project.slug
21
+
22
+ @observer.observe(
23
+ "text-to-speech",
24
+ type: :generation,
25
+ parameters:,
26
+ trace_name:,
27
+ prompt:
28
+ ) do |generation|
29
+ workflow(message:, parameters:, trace_id: generation.trace_id)
30
+ end
31
+ end
32
+
33
+ private
34
+
35
+ def handle_prompt(parameters:)
36
+ return nil if parameters[:prompt_name].blank?
37
+
38
+ prompt = @observer.project_client.project.prompt_store.get_prompt(
39
+ prompt_name: parameters[:prompt_name],
40
+ prompt_label: parameters[:prompt_label],
41
+ prompt_version: parameters[:prompt_version]
42
+ )
43
+ prompt_variables = parameters[:prompt_variables] || {}
44
+
45
+ if prompt.present?
46
+ # Prompts for tts should only be text
47
+ if prompt.type != "text"
48
+ raise ObservedTextToSpeechPromptError,
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
55
+ end
56
+
57
+ prompt
58
+ end
59
+
60
+ def handle_text_to_speech_upload(tts_file, trace_id)
61
+ upload_handler = NitroIntelligence::Observability::UploadHandler.new(
62
+ auth_token: @observer.project_client.project.auth_token
63
+ )
64
+ upload_handler.upload(
65
+ trace_id,
66
+ upload_queue: Queue.new([Audio.new(tts_file)])
67
+ )
68
+
69
+ uploaded_media = upload_handler.uploaded_media.first
70
+ "@@@langfuseMedia:type=#{uploaded_media.mime_type}|id=#{uploaded_media.reference_id}|source=bytes@@@"
71
+ end
72
+
73
+ def workflow(message:, parameters:, trace_id:)
74
+ tts = @base_handler.perform_request(message:, parameters:)
75
+ output = ""
76
+
77
+ Tempfile.create(["tts", ".#{parameters[:response_format]}"]) do |tempfile|
78
+ tempfile.binmode
79
+ tempfile.write(tts.string)
80
+ tempfile.rewind
81
+
82
+ output = handle_text_to_speech_upload(tempfile, trace_id)
83
+ end
84
+
85
+ # We only get StringIO object as a response
86
+ # We dont have usage on tokens and the actual model that was used
87
+ # We will log the requested model instead
88
+ trace_attributes = {
89
+ model: parameters[:model],
90
+ input: message,
91
+ output:,
92
+ }
93
+
94
+ [tts, trace_attributes]
95
+ end
96
+ end
97
+ end
98
+ end
99
+ end
100
+ end
@@ -0,0 +1,56 @@
1
+ require "openai"
2
+ require "nitro_intelligence/client/handlers/base_handler"
3
+
4
+ module NitroIntelligence
5
+ module Client
6
+ module Handlers
7
+ class TextToSpeechHandler < BaseHandler
8
+ ALLOWED_EXTRA_PARAMETERS = OpenAI::Models::Audio::SpeechCreateParams.fields.keys.uniq.freeze
9
+
10
+ def create(message: "", parameters: {})
11
+ validate_and_resolve!(parameters)
12
+ perform_request(message:, parameters:)
13
+ end
14
+
15
+ def perform_request(message: "", parameters: {})
16
+ add_request_headers(parameters, MODALITY_HEADER => "audio", REQUESTED_MODEL_HEADER => parameters[:model])
17
+ @client.audio.speech.create(
18
+ input: message,
19
+ **parameters.slice(*ALLOWED_EXTRA_PARAMETERS)
20
+ )
21
+ end
22
+
23
+ def validate_and_resolve!(parameters)
24
+ model_name = parameters[:model] || NitroIntelligence.model_catalog.default_text_to_speech_model&.name
25
+ model = NitroIntelligence.model_catalog.lookup_by_name(model_name)
26
+ raise ArgumentError, "Unsupported model: '#{model_name}'" unless model
27
+
28
+ default_parameters = {
29
+ metadata: {},
30
+ model: model.name,
31
+ voice: model.default_voice,
32
+ response_format: model.default_response_format,
33
+ }
34
+ parameters.replace(default_parameters.merge(parameters))
35
+ validate_voice_and_format!(model, parameters)
36
+ end
37
+
38
+ private
39
+
40
+ def validate_voice_and_format!(model, parameters)
41
+ unless model.voices.include?(parameters[:voice])
42
+ raise ArgumentError,
43
+ "Unsupported voice: '#{parameters[:voice]}'. " \
44
+ "Supported voices for #{model.name} are: #{model.voices}"
45
+ end
46
+
47
+ return if model.response_formats.include?(parameters[:response_format])
48
+
49
+ raise ArgumentError,
50
+ "Unsupported response_format: '#{parameters[:response_format]}'. " \
51
+ "Supported response_formats for #{model.name} are: #{model.response_formats}"
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
@@ -1,6 +1,7 @@
1
1
  require "nitro_intelligence/client/handlers/observed/audio_transcription_handler"
2
2
  require "nitro_intelligence/client/handlers/observed/chat_handler"
3
3
  require "nitro_intelligence/client/handlers/observed/image_handler"
4
+ require "nitro_intelligence/client/handlers/observed/text_to_speech_handler"
4
5
  require "nitro_intelligence/observability/prompt_store"
5
6
  require "nitro_intelligence/observability/upload_handler"
6
7
  require "nitro_intelligence/trace"
@@ -33,6 +34,12 @@ module NitroIntelligence
33
34
  base_handler: Handlers::ImageHandler.new(client: @client), observer: @observer
34
35
  )
35
36
  end
37
+
38
+ def text_to_speech_handler
39
+ @text_to_speech_handler ||= Handlers::Observed::TextToSpeechHandler.new(
40
+ base_handler: Handlers::TextToSpeechHandler.new(client: @client), observer: @observer
41
+ )
42
+ end
36
43
  end
37
44
  end
38
45
  end
@@ -8,8 +8,6 @@ module NitroIntelligence
8
8
  end
9
9
  end
10
10
 
11
- class TextModel < Model; end
12
-
13
11
  class ImageModel < Model
14
12
  attr_reader :aspect_ratios, :resolutions
15
13
 
@@ -19,4 +17,28 @@ module NitroIntelligence
19
17
  @resolutions = resolutions
20
18
  end
21
19
  end
20
+
21
+ class TextModel < Model; end
22
+
23
+ class TextToSpeechModel < Model
24
+ attr_reader :default_voice,
25
+ :default_response_format,
26
+ :voices,
27
+ :response_formats
28
+
29
+ def initialize(
30
+ name:,
31
+ default_voice:,
32
+ default_response_format:,
33
+ voices: [],
34
+ response_formats: [],
35
+ **
36
+ )
37
+ super
38
+ @default_voice = default_voice
39
+ @default_response_format = default_response_format
40
+ @voices = voices
41
+ @response_formats = response_formats
42
+ end
43
+ end
22
44
  end
@@ -2,13 +2,19 @@ require "nitro_intelligence/models/model_factory"
2
2
 
3
3
  module NitroIntelligence
4
4
  class ModelCatalog
5
- attr_reader :models, :default_audio_transcription_model, :default_image_model, :default_text_model
5
+ attr_reader :models,
6
+ :default_audio_transcription_model,
7
+ :default_image_model,
8
+ :default_text_model,
9
+ :default_text_to_speech_model
6
10
 
7
11
  def initialize(model_config)
12
+ model_config = model_config.symbolize_keys
8
13
  @models = (model_config[:models] || []).map { |model_metadata| ModelFactory.build(model_metadata) }
9
14
  @default_audio_transcription_model = lookup_by_name(model_config[:default_audio_transcription_model])
10
15
  @default_image_model = lookup_by_name(model_config[:default_image_model])
11
16
  @default_text_model = lookup_by_name(model_config[:default_text_model])
17
+ @default_text_to_speech_model = lookup_by_name(model_config[:default_text_to_speech_model])
12
18
  end
13
19
 
14
20
  def lookup_by_name(name)
@@ -2,18 +2,18 @@ require "nitro_intelligence/models/model"
2
2
 
3
3
  module NitroIntelligence
4
4
  class ModelFactory
5
+ TYPES = {
6
+ "text" => TextModel,
7
+ "audio_transcription" => TextModel,
8
+ "image" => ImageModel,
9
+ "text_to_speech" => TextToSpeechModel,
10
+ }.freeze
11
+
5
12
  def self.build(model_metadata)
6
13
  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)
14
+ type = model_metadata[:type]
15
+ model_class = TYPES.fetch(type) { raise ArgumentError, "Unknown model type: #{type.inspect}" }
16
+ model_class.new(**model_metadata)
17
17
  end
18
18
  end
19
19
  end
@@ -1,6 +1,7 @@
1
1
  require "base64"
2
2
  require "cgi"
3
- require "httparty"
3
+ require "net/http"
4
+ require "uri"
4
5
 
5
6
  require "nitro_intelligence/observability/prompt"
6
7
 
@@ -75,15 +76,17 @@ module NitroIntelligence
75
76
 
76
77
  def get_prompt_request(safe_prompt_name:, prompt_url_params:)
77
78
  auth_token = Base64.strict_encode64("#{@observability_public_key}:#{@observability_secret_key}")
78
- response = HTTParty.get(
79
- "#{@observability_host}/api/public/v2/prompts/#{safe_prompt_name}?#{prompt_url_params}",
80
- headers: {
81
- "Authorization" => "Basic #{auth_token}",
82
- }
83
- )
79
+ uri = URI("#{@observability_host}/api/public/v2/prompts/#{safe_prompt_name}?#{prompt_url_params}")
80
+ http = Net::HTTP.new(uri.host, uri.port)
81
+ http.use_ssl = uri.scheme == "https"
82
+
83
+ request = Net::HTTP::Get.new(uri)
84
+ request["Authorization"] = "Basic #{auth_token}"
85
+
86
+ response = http.request(request)
84
87
 
85
- if response.code != 200
86
- raise ObservabilityPromptNotFoundError, "Prompt: #{safe_prompt_name} Not Found" if response.code == 404
88
+ if response.code.to_i != 200
89
+ raise ObservabilityPromptNotFoundError, "Prompt: #{safe_prompt_name} Not Found" if response.code.to_i == 404
87
90
 
88
91
  raise ObservabilityPromptError, response.body
89
92
  end
@@ -1,11 +1,15 @@
1
1
  require "base64"
2
2
  require "digest"
3
- require "httparty"
3
+ require "json"
4
+ require "net/http"
4
5
  require "time"
6
+ require "uri"
5
7
 
6
8
  module NitroIntelligence
7
9
  module Observability
8
10
  class UploadHandler
11
+ attr_reader :uploaded_media
12
+
9
13
  def initialize(auth_token:)
10
14
  @host = NitroIntelligence.config.observability_base_url
11
15
  @auth_token = auth_token
@@ -92,43 +96,50 @@ module NitroIntelligence
92
96
  private
93
97
 
94
98
  def get_upload_url(request_body)
95
- HTTParty.post(
96
- "#{@host}/api/public/media",
97
- body: request_body.to_json,
98
- headers: {
99
- "Content-Type" => "application/json",
100
- "Authorization" => "Basic #{@auth_token}",
101
- }
102
- )
99
+ uri = URI("#{@host}/api/public/media")
100
+ http = Net::HTTP.new(uri.host, uri.port)
101
+ http.use_ssl = uri.scheme == "https"
102
+
103
+ request = Net::HTTP::Post.new(uri)
104
+ request["Content-Type"] = "application/json"
105
+ request["Authorization"] = "Basic #{@auth_token}"
106
+ request.body = request_body.to_json
107
+
108
+ response = http.request(request)
109
+ JSON.parse(response.body)
103
110
  end
104
111
 
105
112
  def associate_media(media_id, upload_response)
106
113
  request_body = {
107
114
  uploadedAt: Time.now.utc.iso8601(6),
108
- uploadHttpStatus: upload_response.code,
109
- uploadHttpError: upload_response.code == 200 ? nil : upload_response.body,
115
+ uploadHttpStatus: upload_response.code.to_i,
116
+ uploadHttpError: upload_response.code.to_i == 200 ? nil : upload_response.body,
110
117
  }
111
118
 
112
- HTTParty.patch(
113
- "#{@host}/api/public/media/#{media_id}",
114
- body: request_body.to_json,
115
- headers: {
116
- "Content-Type" => "application/json",
117
- "Authorization" => "Basic #{@auth_token}",
118
- }
119
- )
119
+ uri = URI("#{@host}/api/public/media/#{media_id}")
120
+ http = Net::HTTP.new(uri.host, uri.port)
121
+ http.use_ssl = uri.scheme == "https"
122
+
123
+ request = Net::HTTP::Patch.new(uri)
124
+ request["Content-Type"] = "application/json"
125
+ request["Authorization"] = "Basic #{@auth_token}"
126
+ request.body = request_body.to_json
127
+
128
+ http.request(request)
120
129
  end
121
130
 
122
131
  def upload_media(media_id, upload_url, content_type, content_sha256, content_bytes)
123
132
  if media_id.present? && upload_url.present?
124
- return HTTParty.put(
125
- upload_url,
126
- headers: {
127
- "Content-Type" => content_type,
128
- "x-amz-checksum-sha256" => content_sha256,
129
- },
130
- body: content_bytes
131
- )
133
+ uri = URI(upload_url)
134
+ http = Net::HTTP.new(uri.host, uri.port)
135
+ http.use_ssl = uri.scheme == "https"
136
+
137
+ request = Net::HTTP::Put.new(uri)
138
+ request["Content-Type"] = content_type
139
+ request["x-amz-checksum-sha256"] = content_sha256
140
+ request.body = content_bytes
141
+
142
+ return http.request(request)
132
143
  end
133
144
 
134
145
  nil
@@ -1,5 +1,6 @@
1
1
  require "base64"
2
- require "httparty"
2
+ require "net/http"
3
+ require "uri"
3
4
 
4
5
  module NitroIntelligence
5
6
  class Reporter
@@ -10,12 +11,16 @@ module NitroIntelligence
10
11
  end
11
12
 
12
13
  def create_dataset_item(attributes)
13
- HTTParty.post("#{@host}/api/public/dataset-items",
14
- body: attributes.to_json,
15
- headers: {
16
- "Content-Type" => "application/json",
17
- "Authorization" => "Basic #{@project_client.project.auth_token}",
18
- })
14
+ uri = URI("#{@host}/api/public/dataset-items")
15
+ http = Net::HTTP.new(uri.host, uri.port)
16
+ http.use_ssl = uri.scheme == "https"
17
+
18
+ request = Net::HTTP::Post.new(uri)
19
+ request["Content-Type"] = "application/json"
20
+ request["Authorization"] = "Basic #{@project_client.project.auth_token}"
21
+ request.body = attributes.to_json
22
+
23
+ http.request(request)
19
24
  end
20
25
 
21
26
  def score(trace_id:, name:, value:, id: "#{trace_id}-#{name}")
@@ -1,3 +1,3 @@
1
1
  module NitroIntelligence
2
- VERSION = "1.0.1".freeze
2
+ VERSION = "2.1.0".freeze
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: nitro_intelligence
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.1
4
+ version: 2.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Igor Artemenko
@@ -23,20 +23,6 @@ dependencies:
23
23
  - - "~>"
24
24
  - !ruby/object:Gem::Version
25
25
  version: '7.1'
26
- - !ruby/object:Gem::Dependency
27
- name: httparty
28
- requirement: !ruby/object:Gem::Requirement
29
- requirements:
30
- - - "~>"
31
- - !ruby/object:Gem::Version
32
- version: '0.16'
33
- type: :runtime
34
- prerelease: false
35
- version_requirements: !ruby/object:Gem::Requirement
36
- requirements:
37
- - - "~>"
38
- - !ruby/object:Gem::Version
39
- version: '0.16'
40
26
  - !ruby/object:Gem::Dependency
41
27
  name: langfuse-rb
42
28
  requirement: !ruby/object:Gem::Requirement
@@ -79,152 +65,6 @@ dependencies:
79
65
  - - "~>"
80
66
  - !ruby/object:Gem::Version
81
67
  version: '0.58'
82
- - !ruby/object:Gem::Dependency
83
- name: railties
84
- requirement: !ruby/object:Gem::Requirement
85
- requirements:
86
- - - "~>"
87
- - !ruby/object:Gem::Version
88
- version: '7.1'
89
- type: :runtime
90
- prerelease: false
91
- version_requirements: !ruby/object:Gem::Requirement
92
- requirements:
93
- - - "~>"
94
- - !ruby/object:Gem::Version
95
- version: '7.1'
96
- - !ruby/object:Gem::Dependency
97
- name: license_finder
98
- requirement: !ruby/object:Gem::Requirement
99
- requirements:
100
- - - '='
101
- - !ruby/object:Gem::Version
102
- version: 7.2.1
103
- type: :development
104
- prerelease: false
105
- version_requirements: !ruby/object:Gem::Requirement
106
- requirements:
107
- - - '='
108
- - !ruby/object:Gem::Version
109
- version: 7.2.1
110
- - !ruby/object:Gem::Dependency
111
- name: parser
112
- requirement: !ruby/object:Gem::Requirement
113
- requirements:
114
- - - ">="
115
- - !ruby/object:Gem::Version
116
- version: '2.5'
117
- - - "!="
118
- - !ruby/object:Gem::Version
119
- version: 2.5.1.1
120
- type: :development
121
- prerelease: false
122
- version_requirements: !ruby/object:Gem::Requirement
123
- requirements:
124
- - - ">="
125
- - !ruby/object:Gem::Version
126
- version: '2.5'
127
- - - "!="
128
- - !ruby/object:Gem::Version
129
- version: 2.5.1.1
130
- - !ruby/object:Gem::Dependency
131
- name: pry
132
- requirement: !ruby/object:Gem::Requirement
133
- requirements:
134
- - - '='
135
- - !ruby/object:Gem::Version
136
- version: 0.14.2
137
- type: :development
138
- prerelease: false
139
- version_requirements: !ruby/object:Gem::Requirement
140
- requirements:
141
- - - '='
142
- - !ruby/object:Gem::Version
143
- version: 0.14.2
144
- - !ruby/object:Gem::Dependency
145
- name: pry-byebug
146
- requirement: !ruby/object:Gem::Requirement
147
- requirements:
148
- - - '='
149
- - !ruby/object:Gem::Version
150
- version: 3.10.1
151
- type: :development
152
- prerelease: false
153
- version_requirements: !ruby/object:Gem::Requirement
154
- requirements:
155
- - - '='
156
- - !ruby/object:Gem::Version
157
- version: 3.10.1
158
- - !ruby/object:Gem::Dependency
159
- name: rainbow
160
- requirement: !ruby/object:Gem::Requirement
161
- requirements:
162
- - - '='
163
- - !ruby/object:Gem::Version
164
- version: 2.2.2
165
- type: :development
166
- prerelease: false
167
- version_requirements: !ruby/object:Gem::Requirement
168
- requirements:
169
- - - '='
170
- - !ruby/object:Gem::Version
171
- version: 2.2.2
172
- - !ruby/object:Gem::Dependency
173
- name: rspec
174
- requirement: !ruby/object:Gem::Requirement
175
- requirements:
176
- - - '='
177
- - !ruby/object:Gem::Version
178
- version: 3.13.0
179
- type: :development
180
- prerelease: false
181
- version_requirements: !ruby/object:Gem::Requirement
182
- requirements:
183
- - - '='
184
- - !ruby/object:Gem::Version
185
- version: 3.13.0
186
- - !ruby/object:Gem::Dependency
187
- name: rubocop-powerhome
188
- requirement: !ruby/object:Gem::Requirement
189
- requirements:
190
- - - '='
191
- - !ruby/object:Gem::Version
192
- version: 0.6.1
193
- type: :development
194
- prerelease: false
195
- version_requirements: !ruby/object:Gem::Requirement
196
- requirements:
197
- - - '='
198
- - !ruby/object:Gem::Version
199
- version: 0.6.1
200
- - !ruby/object:Gem::Dependency
201
- name: webmock
202
- requirement: !ruby/object:Gem::Requirement
203
- requirements:
204
- - - '='
205
- - !ruby/object:Gem::Version
206
- version: 3.26.1
207
- type: :development
208
- prerelease: false
209
- version_requirements: !ruby/object:Gem::Requirement
210
- requirements:
211
- - - '='
212
- - !ruby/object:Gem::Version
213
- version: 3.26.1
214
- - !ruby/object:Gem::Dependency
215
- name: yard
216
- requirement: !ruby/object:Gem::Requirement
217
- requirements:
218
- - - '='
219
- - !ruby/object:Gem::Version
220
- version: 0.9.37
221
- type: :development
222
- prerelease: false
223
- version_requirements: !ruby/object:Gem::Requirement
224
- requirements:
225
- - - '='
226
- - !ruby/object:Gem::Version
227
- version: 0.9.37
228
68
  description: The Ruby client for Nitro Intelligence
229
69
  email:
230
70
  - igor.artemenko@powerhrg.com
@@ -240,11 +80,14 @@ files:
240
80
  - lib/nitro_intelligence/client/client.rb
241
81
  - lib/nitro_intelligence/client/factory.rb
242
82
  - lib/nitro_intelligence/client/handlers/audio_transcription_handler.rb
83
+ - lib/nitro_intelligence/client/handlers/base_handler.rb
243
84
  - lib/nitro_intelligence/client/handlers/chat_handler.rb
244
85
  - lib/nitro_intelligence/client/handlers/image_handler.rb
245
86
  - lib/nitro_intelligence/client/handlers/observed/audio_transcription_handler.rb
246
87
  - lib/nitro_intelligence/client/handlers/observed/chat_handler.rb
247
88
  - lib/nitro_intelligence/client/handlers/observed/image_handler.rb
89
+ - lib/nitro_intelligence/client/handlers/observed/text_to_speech_handler.rb
90
+ - lib/nitro_intelligence/client/handlers/text_to_speech_handler.rb
248
91
  - lib/nitro_intelligence/client/observed.rb
249
92
  - lib/nitro_intelligence/client/observers/langfuse_observer.rb
250
93
  - lib/nitro_intelligence/configuration.rb