nitro_intelligence 0.0.1 → 1.0.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.
Files changed (35) hide show
  1. checksums.yaml +4 -4
  2. data/docs/README.md +83 -11
  3. data/lib/nitro_intelligence/agent_server.rb +119 -8
  4. data/lib/nitro_intelligence/client/base.rb +52 -0
  5. data/lib/nitro_intelligence/client/client.rb +13 -0
  6. data/lib/nitro_intelligence/client/factory.rb +53 -0
  7. data/lib/nitro_intelligence/client/handlers/audio_transcription_handler.rb +38 -0
  8. data/lib/nitro_intelligence/client/handlers/chat_handler.rb +41 -0
  9. data/lib/nitro_intelligence/client/handlers/image_handler.rb +61 -0
  10. data/lib/nitro_intelligence/client/handlers/observed/audio_transcription_handler.rb +90 -0
  11. data/lib/nitro_intelligence/client/handlers/observed/chat_handler.rb +74 -0
  12. data/lib/nitro_intelligence/client/handlers/observed/image_handler.rb +109 -0
  13. data/lib/nitro_intelligence/client/observed.rb +38 -0
  14. data/lib/nitro_intelligence/client/observers/langfuse_observer.rb +75 -0
  15. data/lib/nitro_intelligence/configuration.rb +2 -0
  16. data/lib/nitro_intelligence/langfuse_extension.rb +10 -53
  17. data/lib/nitro_intelligence/media/audio.rb +50 -0
  18. data/lib/nitro_intelligence/media/image_generation.rb +4 -2
  19. data/lib/nitro_intelligence/models/model_catalog.rb +7 -2
  20. data/lib/nitro_intelligence/observability/project.rb +33 -0
  21. data/lib/nitro_intelligence/observability/project_client.rb +18 -0
  22. data/lib/nitro_intelligence/observability/project_client_registry.rb +44 -0
  23. data/lib/nitro_intelligence/observability/prompt.rb +58 -0
  24. data/lib/nitro_intelligence/observability/prompt_store.rb +112 -0
  25. data/lib/nitro_intelligence/observability/upload_handler.rb +138 -0
  26. data/lib/nitro_intelligence/reporter.rb +43 -0
  27. data/lib/nitro_intelligence/tool_call_review_validator.rb +69 -0
  28. data/lib/nitro_intelligence/trace.rb +2 -2
  29. data/lib/nitro_intelligence/version.rb +1 -1
  30. data/lib/nitro_intelligence.rb +10 -44
  31. metadata +26 -10
  32. data/lib/nitro_intelligence/client.rb +0 -337
  33. data/lib/nitro_intelligence/media/upload_handler.rb +0 -135
  34. data/lib/nitro_intelligence/prompt/prompt.rb +0 -56
  35. data/lib/nitro_intelligence/prompt/prompt_store.rb +0 -110
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: aa22292af25ca70dd3a19ba62830fa25606a84517af6f7bb79906ceea4d0e058
4
- data.tar.gz: 07c68668426d3feb046e633203e617ede8f9ef807a3f60341c347fbb0c05b17f
3
+ metadata.gz: dcac30052d3ab8db387569038d73304349941c60d224879e67fe9116247397ff
4
+ data.tar.gz: 9dca9e4c1b4f3669c65d3889c36b20d502b8b910b4b6c0bf001eee40e8eabc9c
5
5
  SHA512:
6
- metadata.gz: 02d7bcd08eb45f6dc14c0b4a5e5f91e0edac92369435721e838a0e623a5a26b4af33b0f851c1d02de54ca6d80a864d0b8904e9a78612e9d14aa642e8993865ba
7
- data.tar.gz: e6c1bfc0896d5d11cc017e23aef1b617c3f83182c09fe8a6414202d66dfbdc5d2e13fe73bf3b5ef54e928d5f1cf944ad001781397bab7a8a5f4867e4a152cc1c
6
+ metadata.gz: 1e5eec8f160d35bb7fac0d12b3d47d1a84e0694ad98d2ddb3f301fb6b578f7970895a60240ddcd5eb04af95a9ee9fdcd79959bcc8aadfa17f5cc434351331ca6
7
+ data.tar.gz: a565c1662fbfbee8768c9f21691dad16401f34205d0c2b6f463b0e165ede2f15e602e0f84d569291cd37ccd56a3af7bacb6d1c8a71a91d698beefba57059b867
data/docs/README.md CHANGED
@@ -38,16 +38,16 @@ end
38
38
 
39
39
  ### Configuration Keys
40
40
 
41
- | Key | Type | Default | Description |
42
- |---|---|---|---|
43
- | `logger` | `Logger` | `Logger.new($stdout)` | Logger used for diagnostic output |
44
- | `environment` | `String` | `"test"` | Runtime environment name |
45
- | `cache_provider` | cache store | `NullCache` | ActiveSupport-compatible cache store |
46
- | `inference_api_key` | `String` | `""` | API key for the LLM inference service |
47
- | `inference_base_url` | `String` | `""` | Base URL for the LLM inference service |
48
- | `observability_base_url` | `String` | `""` | Base URL for the Langfuse observability service |
49
- | `observability_projects` | `Array<Hash>` | `[]` | Langfuse project credentials (slug, id, public_key, secret_key) |
50
- | `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 |
41
+ | Key | Type | Default | Description |
42
+ | ------------------------ | ------------- | --------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
43
+ | `logger` | `Logger` | `Logger.new($stdout)` | Logger used for diagnostic output |
44
+ | `environment` | `String` | `"test"` | Runtime environment name |
45
+ | `cache_provider` | cache store | `NullCache` | ActiveSupport-compatible cache store |
46
+ | `inference_api_key` | `String` | `""` | API key for the LLM inference service |
47
+ | `inference_base_url` | `String` | `""` | Base URL for the LLM inference service |
48
+ | `observability_base_url` | `String` | `""` | Base URL for the Langfuse observability service |
49
+ | `observability_projects` | `Array<Hash>` | `[]` | Langfuse project credentials (slug, id, public_key, secret_key) |
50
+ | `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 |
51
51
 
52
52
  ## Basic Usage
53
53
 
@@ -70,7 +70,6 @@ client = NitroIntelligence::Client.new
70
70
  client.chat(parameters: { model: "meta-llama/Llama-3.1-8B-Instruct", messages: [{ role: "user", content: "Why is the sky blue?" }]})
71
71
  ```
72
72
 
73
-
74
73
  #### Providing Parameters
75
74
 
76
75
  Parameters such as 'max_tokens' and 'temperature' can be passed in under the `parameters` key.
@@ -82,6 +81,36 @@ client.chat(parameters: { model: "meta-llama/Llama-3.1-8B-Instruct", max_tokens:
82
81
 
83
82
  For a full list of supported parameters, see the [API reference here](https://developers.openai.com/api/reference/resources/completions/methods/create).
84
83
 
84
+ ### Audio Transcription
85
+
86
+ Nitro Intelligence can be used to transcribe audio from a file into text.
87
+
88
+ Basic examples of usage:
89
+
90
+ ```ruby
91
+ client = NitroIntelligence::Client.new
92
+ audio = File.open('sample.m4a', 'rb')
93
+ result = client.transcribe_audio(audio_file: audio)
94
+ ```
95
+
96
+ `result` is a `OpenAI::Models::Audio::Transcription` object:
97
+
98
+ ```ruby
99
+ <OpenAI::Models::Audio::Transcription:0x2fdc8 {:text=>"Hello, how are you doing today?", :usage=>{:input_tokens=>28, :output_tokens=>10, :total_tokens=>38, :type=>:tokens, :input_token_details=>{:audio_tokens=>28, :text_tokens=>0}}}>
100
+ ```
101
+
102
+ To use a prompt, simply initialize your client with the `observability_project_slug` and pass `prompt_name` into parameters:
103
+
104
+ ```ruby
105
+ client = NitroIntelligence::Client.new(observability_project_slug: "my-test-project")
106
+ audio = File.open('sample.m4a', 'rb')
107
+ result = client.transcribe_audio(audio_file: audio, parameters: {prompt_name: "Spanish Converter"})
108
+
109
+ puts result
110
+
111
+ # <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
+ ```
113
+
85
114
  ### Image Editing and Generation
86
115
 
87
116
  Nitro Intelligence can be used for image editing and generation
@@ -175,6 +204,43 @@ client.chat(
175
204
  )
176
205
  ```
177
206
 
207
+ ### Custom Trace IDs
208
+
209
+ To generate a deterministic trace ID in the observability platform, you can pass `trace_seed` as a parameter. This is useful when you want a stable identifier that is derived from a specific domain value (e.g., document ID). The same `trace_seed` will produce the same trace ID, making it easier to correlate multiple events.
210
+
211
+ ```ruby
212
+ document_id = "document-123"
213
+ client = NitroIntelligence::Client.new(observability_project_slug: "fake-feature-project")
214
+ client.chat(
215
+ message: "summarize the document",
216
+ parameters: {
217
+ trace_seed: document_id,
218
+ }
219
+ )
220
+ ```
221
+
222
+ ### Scoring
223
+
224
+ You can use `NitroIntelligence::Reporter` to evaluate existing traces. Calling `NitroIntelligence::Reporter#score` lets you attach metrics to a trace in the observability platform.
225
+
226
+ #### By Trace ID
227
+
228
+ ```ruby
229
+ reporter = NitroIntelligence::Reporter.new(observability_project_slug: "fake-feature-project")
230
+ reporter.score(name: "precision", value: 0.5, trace_id: "2377b0b38acf0f11b95504344fad6152")
231
+ ```
232
+
233
+ #### By Trace Seed
234
+
235
+ Knowing the trace seed, you can use `NitroIntelligence::Trace.create_id` to get the trace ID.
236
+
237
+ ```ruby
238
+ document_id = "document-123"
239
+ reporter = NitroIntelligence::Reporter.new(observability_project_slug: "fake-feature-project")
240
+ trace_id = NitroIntelligence::Trace.create_id(seed: document_id)
241
+ reporter.score(name: "precision", value: 0.5, trace_id:)
242
+ ```
243
+
178
244
  ### Prompt Variables and Config
179
245
 
180
246
  Prompts are often created with "variables". These variables can be supplied and compiled into the prompt. For example:
@@ -245,3 +311,9 @@ client.chat(
245
311
  }
246
312
  )
247
313
  ```
314
+
315
+ ## Agent Server
316
+
317
+ The Agent Server is Nitro Intelligence's lightweight SDK for working with hosted agent threads, runs, and human review flows. It is mainly used to initialize conversation threads, trigger agent runs, inspect agent tool calls pending human approval, and resume interrupted threads after human reviews.
318
+
319
+ For the full Agent Server guide, see [AGENT_SERVER.md](AGENT_SERVER.md).
@@ -1,8 +1,11 @@
1
+ require "nitro_intelligence/tool_call_review_validator"
2
+
1
3
  module NitroIntelligence
2
4
  class AgentServer
3
5
  class ConfigurationError < StandardError; end
4
6
  class ThreadInitializationError < StandardError; end
5
7
  class RunError < StandardError; end
8
+ class ThreadResumptionError < StandardError; end
6
9
 
7
10
  attr_reader :base_url, :user_id
8
11
 
@@ -14,19 +17,61 @@ module NitroIntelligence
14
17
  @base_url = base_url
15
18
  @api_key = api_key
16
19
  @user_id = user_id
20
+ @tool_call_review_validator = ToolCallReviewValidator.new
17
21
  end
18
22
 
19
23
  def await_run(thread_id:, assistant_id:, messages:, context: {})
20
24
  raise RunError, "messages cannot be empty" if messages.blank?
21
25
 
22
- *initial_state, last_message = messages
23
- initial_state = [] if messages.size == 1
24
- last_message = messages.first if messages.size == 1
26
+ initial_state = messages[0..-2]
27
+ last_message = messages.last
25
28
 
26
29
  initialize_thread_if_needed(thread_id:, initial_state:)
27
30
  trigger_run(thread_id:, assistant_id:, context:, last_message:)
28
31
  end
29
32
 
33
+ def tool_calls_pending_review(thread_id:)
34
+ thread_state = get_thread_state(thread_id:)
35
+ messages = thread_messages(thread_state)
36
+ reviewed_tool_call_ids = tool_messages(messages).map { |message| message["tool_call_id"] }
37
+
38
+ messages.each_with_index.flat_map do |message, index|
39
+ next [] unless message["type"] == "ai"
40
+
41
+ pending_tool_calls(message, reviewed_tool_call_ids).map do |tool_call|
42
+ {
43
+ "previous_message_id" => index.zero? ? nil : messages[index - 1]&.dig("id"),
44
+ "id" => tool_call["id"],
45
+ "name" => tool_call["name"],
46
+ "args" => tool_call["args"] || {},
47
+ }
48
+ end
49
+ end
50
+ end
51
+
52
+ def review_tool_calls(thread_id:, assistant_id:, reviewer_id:, tool_calls:, reviewed_at: DateTime.current.iso8601)
53
+ resume = { reviewer_id:, reviewed_at:, tool_calls: }.with_indifferent_access
54
+ thread = get_thread(thread_id:)
55
+ raise ThreadResumptionError, "Thread #{thread_id} is not in the interrupted state" unless interrupted?(thread)
56
+
57
+ thread_state = get_thread_state(thread_id:)
58
+
59
+ @tool_call_review_validator.validate!(
60
+ thread_state:,
61
+ tool_calls: resume[:tool_calls],
62
+ pending_tool_calls: tool_calls_pending_review(thread_id:)
63
+ )
64
+
65
+ resume_run(
66
+ thread_id:,
67
+ assistant_id:,
68
+ resume:,
69
+ context: interrupt_context(thread_state)
70
+ )
71
+
72
+ nil
73
+ end
74
+
30
75
  private
31
76
 
32
77
  def initialize_thread_if_needed(thread_id:, initial_state:)
@@ -45,6 +90,22 @@ module NitroIntelligence
45
90
  thread_response
46
91
  end
47
92
 
93
+ def get_thread_state(thread_id:)
94
+ state_response = get(path: "/threads/#{thread_id}/state")
95
+
96
+ raise ThreadResumptionError, state_response.body if state_response.code != 200
97
+
98
+ state_response
99
+ end
100
+
101
+ def get_thread(thread_id:)
102
+ thread_response = get(path: "/threads/#{thread_id}")
103
+
104
+ raise ThreadResumptionError, thread_response.body if thread_response.code != 200
105
+
106
+ thread_response
107
+ end
108
+
48
109
  def trigger_run(thread_id:, assistant_id:, last_message:, context: {})
49
110
  run_response = post(
50
111
  path: "/threads/#{thread_id}/runs/wait",
@@ -59,18 +120,68 @@ module NitroIntelligence
59
120
 
60
121
  raise RunError, run_response.body if run_response.code != 200
61
122
 
62
- run_response["messages"].last["content"]
123
+ Array(run_response["messages"]).last&.dig("content")
124
+ end
125
+
126
+ def resume_run(thread_id:, assistant_id:, resume:, context:)
127
+ run_response = post(
128
+ path: "/threads/#{thread_id}/runs/wait",
129
+ body: {
130
+ assistant_id:,
131
+ command: {
132
+ resume:,
133
+ },
134
+ context:,
135
+ }
136
+ )
137
+
138
+ raise ThreadResumptionError, run_response.body if run_response.code != 200
139
+
140
+ run_response
141
+ end
142
+
143
+ def interrupted?(thread)
144
+ thread["status"] == "interrupted"
145
+ end
146
+
147
+ def interrupt_context(thread_state)
148
+ thread_state.dig("interrupts", 0, "value", "context") || {}
149
+ end
150
+
151
+ def thread_messages(thread_state)
152
+ Array(thread_state.dig("values", "messages"))
153
+ end
154
+
155
+ def tool_messages(messages)
156
+ messages.select { |message| message["type"] == "tool" }
157
+ end
158
+
159
+ def pending_tool_calls(message, reviewed_tool_call_ids)
160
+ Array(message["tool_calls"]).reject do |tool_call|
161
+ reviewed_tool_call_ids.include?(tool_call["id"])
162
+ end
163
+ end
164
+
165
+ def get(path:)
166
+ HTTParty.get(
167
+ "#{base_url}#{path}",
168
+ headers: request_headers
169
+ )
63
170
  end
64
171
 
65
172
  def post(path:, body:)
66
173
  HTTParty.post(
67
174
  "#{base_url}#{path}",
68
- headers: {
69
- "Content-Type" => "application/json",
70
- "Authorization" => "Bearer #{@api_key}",
71
- },
175
+ headers: request_headers,
72
176
  body: body.to_json
73
177
  )
74
178
  end
179
+
180
+ def request_headers
181
+ {
182
+ "Content-Type" => "application/json",
183
+ "Authorization" => "Bearer #{@api_key}",
184
+ }
185
+ end
75
186
  end
76
187
  end
@@ -0,0 +1,52 @@
1
+ require "nitro_intelligence/client/handlers/audio_transcription_handler"
2
+ require "nitro_intelligence/client/handlers/chat_handler"
3
+ require "nitro_intelligence/client/handlers/image_handler"
4
+
5
+ module NitroIntelligence
6
+ module Client
7
+ class Base
8
+ attr_reader :client
9
+
10
+ def initialize(client:)
11
+ @client = client
12
+ end
13
+
14
+ def chat(message: "", parameters: {})
15
+ chat_handler.create(message:, parameters:)
16
+ end
17
+
18
+ # Input images should be byte strings. Returns NitroIntelligence::ImageGeneration
19
+ def generate_image(message: "", target_image: nil, reference_images: [], parameters: {})
20
+ image_handler.create(message:, target_image:, reference_images:, parameters:)
21
+ end
22
+
23
+ # Audio file should be file object with extension in the filename
24
+ # Use file_extension for now to prevent a library dependency
25
+ def transcribe_audio(message: +"", audio_file: nil, parameters: {})
26
+ audio_transcription_handler.create(message:, audio_file:, parameters:)
27
+ end
28
+
29
+ private
30
+
31
+ def audio_transcription_handler
32
+ @audio_transcription_handler ||= Handlers::AudioTranscriptionHandler.new(client: @client)
33
+ end
34
+
35
+ def chat_handler
36
+ @chat_handler ||= Handlers::ChatHandler.new(client: @client)
37
+ end
38
+
39
+ def image_handler
40
+ @image_handler ||= Handlers::ImageHandler.new(client: @client)
41
+ end
42
+
43
+ def method_missing(method_name, *, &)
44
+ @client.send(method_name, *, &)
45
+ end
46
+
47
+ def respond_to_missing?(method_name, include_private = false)
48
+ @client.respond_to?(method_name, include_private) || super
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,13 @@
1
+ require "nitro_intelligence/client/factory"
2
+
3
+ module NitroIntelligence
4
+ module Client
5
+ def self.new(observability_project_slug: nil)
6
+ Factory.new(observability_project_slug:).build
7
+ end
8
+
9
+ def self.validate_model(model)
10
+ raise ArgumentError, "Unsupported model: '#{model}'" unless NitroIntelligence.model_catalog.exists?(model)
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,53 @@
1
+ require "nitro_intelligence/client/base"
2
+ require "nitro_intelligence/client/observed"
3
+ require "nitro_intelligence/client/observers/langfuse_observer"
4
+ require "nitro_intelligence/observability/project"
5
+
6
+ module NitroIntelligence
7
+ module Client
8
+ class Factory
9
+ def initialize(observability_project_slug:)
10
+ @observability_project_slug = observability_project_slug
11
+ end
12
+
13
+ def build
14
+ if @observability_project_slug.present?
15
+ begin
16
+ return Client::Observed.new(
17
+ client: inference_client,
18
+ observer: Client::Observers::LangfuseObserver.new(project_client: fetch_project_client)
19
+ )
20
+ rescue NitroIntelligence::Observability::ProjectClient::NotFoundError,
21
+ NitroIntelligence::Observability::Project::NotFoundError => e
22
+ NitroIntelligence.logger.warn(
23
+ "#{self.class} #{e} - Error raised initializing project - Falling back to base client (no observability)"
24
+ )
25
+ end
26
+ end
27
+
28
+ Client::Base.new(
29
+ client: inference_client
30
+ )
31
+ end
32
+
33
+ private
34
+
35
+ def fetch_project_client
36
+ project_client = NitroIntelligence.project_client_registry.fetch(@observability_project_slug)
37
+ if project_client.nil?
38
+ raise NitroIntelligence::Observability::ProjectClient::NotFoundError,
39
+ "No project session found for slug: #{@observability_project_slug}"
40
+ end
41
+
42
+ project_client
43
+ end
44
+
45
+ def inference_client
46
+ @inference_client ||= OpenAI::Client.new(
47
+ api_key: NitroIntelligence.config.inference_api_key,
48
+ base_url: NitroIntelligence.config.inference_base_url
49
+ )
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,38 @@
1
+ require "openai"
2
+
3
+ module NitroIntelligence
4
+ module Client
5
+ module Handlers
6
+ class AudioTranscriptionHandler
7
+ ALLOWED_EXTRA_PARAMETERS = OpenAI::Models::Audio::TranscriptionCreateParams.fields.keys.uniq.freeze
8
+
9
+ def initialize(client:)
10
+ @client = client
11
+ end
12
+
13
+ def create(audio_file:, message: "", parameters: {})
14
+ validate_and_resolve!(parameters)
15
+ perform_request(audio_file:, message:, parameters:)
16
+ end
17
+
18
+ def perform_request(audio_file:, message: "", parameters: {})
19
+ @client.audio.transcriptions.create(
20
+ prompt: message,
21
+ file: audio_file,
22
+ **parameters.slice(*ALLOWED_EXTRA_PARAMETERS)
23
+ )
24
+ end
25
+
26
+ def validate_and_resolve!(parameters)
27
+ default_parameters = {
28
+ metadata: {},
29
+ model: NitroIntelligence.model_catalog.default_audio_transcription_model&.name,
30
+ }
31
+
32
+ parameters.replace(default_parameters.merge(parameters))
33
+ Client.validate_model(parameters[:model])
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,41 @@
1
+ require "openai"
2
+
3
+ module NitroIntelligence
4
+ module Client
5
+ module Handlers
6
+ class ChatHandler
7
+ ALLOWED_EXTRA_PARAMETERS = OpenAI::Models::Chat::CompletionCreateParams.fields.keys.uniq.freeze
8
+
9
+ def initialize(client:)
10
+ @client = client
11
+ end
12
+
13
+ def create(message: "", parameters: {})
14
+ validate_and_resolve!(parameters, message)
15
+ perform_request(parameters:)
16
+ end
17
+
18
+ def perform_request(parameters: {})
19
+ @client.chat.completions.create(**parameters.slice(*ALLOWED_EXTRA_PARAMETERS))
20
+ end
21
+
22
+ def validate_and_resolve!(parameters, message)
23
+ if parameters[:messages].blank? && message.present?
24
+ parameters[:messages] ||= [{ role: "user",
25
+ content: message }]
26
+ end
27
+
28
+ default_parameters = {
29
+ metadata: {},
30
+ messages: [],
31
+ model: NitroIntelligence.model_catalog.default_text_model&.name,
32
+ extra_headers: { "Prefer" => "wait" },
33
+ }
34
+
35
+ parameters.replace(default_parameters.merge(parameters))
36
+ Client.validate_model(parameters[:model])
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,61 @@
1
+ require "openai"
2
+ require "nitro_intelligence/media/image_generation"
3
+
4
+ module NitroIntelligence
5
+ module Client
6
+ module Handlers
7
+ class ImageHandler
8
+ ALLOWED_EXTRA_PARAMETERS = OpenAI::Models::Chat::CompletionCreateParams.fields.keys.uniq.freeze
9
+
10
+ def initialize(client:)
11
+ @client = client
12
+ end
13
+
14
+ def create(message: "", target_image: nil, reference_images: [], parameters: {})
15
+ image_generation = build_image_generation(message:, target_image:, reference_images:, parameters:)
16
+
17
+ validate_and_resolve!(parameters, image_generation)
18
+
19
+ chat_completion = perform_request(parameters:)
20
+
21
+ image_generation.parse_file(chat_completion)
22
+ image_generation
23
+ end
24
+
25
+ def perform_request(parameters: {})
26
+ @client.chat.completions.create(**parameters.slice(*ALLOWED_EXTRA_PARAMETERS))
27
+ end
28
+
29
+ def validate_and_resolve!(parameters, image_generation)
30
+ default_parameters = {
31
+ image_generation:,
32
+ metadata: {},
33
+ messages: image_generation.messages,
34
+ model: image_generation.config.model,
35
+ extra_headers: { "Prefer" => "wait" },
36
+ request_options: {
37
+ extra_body: {
38
+ image_config: {
39
+ aspect_ratio: image_generation.config.aspect_ratio,
40
+ image_size: image_generation.config.resolution,
41
+ },
42
+ },
43
+ },
44
+ }
45
+ parameters.replace(default_parameters.merge(parameters))
46
+ Client.validate_model(parameters[:model])
47
+ end
48
+
49
+ private
50
+
51
+ def build_image_generation(message:, target_image:, reference_images:, parameters:)
52
+ NitroIntelligence::ImageGeneration.new(message:, target_image:, reference_images:) do |config|
53
+ config.aspect_ratio = parameters[:aspect_ratio] if parameters.key?(:aspect_ratio)
54
+ config.model = parameters[:model] if parameters.key?(:model)
55
+ config.resolution = parameters[:resolution] if parameters.key?(:resolution)
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,90 @@
1
+ require "base64"
2
+ require "nitro_intelligence/media/audio"
3
+
4
+ module NitroIntelligence
5
+ module Client
6
+ module Handlers
7
+ module Observed
8
+ class AudioTranscriptionHandler
9
+ class ObservedAudioTranscriptionPromptError < StandardError; end
10
+
11
+ def initialize(base_handler:, observer:)
12
+ @base_handler = base_handler
13
+ @observer = observer
14
+ end
15
+
16
+ def create(audio_file:, message: +"", parameters: {})
17
+ @base_handler.validate_and_resolve!(parameters)
18
+
19
+ # Modifies message in place
20
+ prompt = handle_prompt(message:, parameters:)
21
+ trace_name = parameters[:trace_name] || prompt&.name || @observer.project_client.project.slug
22
+
23
+ @observer.observe(
24
+ "audio-transcription",
25
+ type: :generation,
26
+ parameters:,
27
+ trace_name:,
28
+ prompt:
29
+ ) do |generation|
30
+ workflow(generation:, message:, audio_file:, parameters:)
31
+ end
32
+ end
33
+
34
+ private
35
+
36
+ def handle_prompt(message:, parameters:)
37
+ return nil if parameters[:prompt_name].blank?
38
+
39
+ prompt = @observer.project_client.project.prompt_store.get_prompt(
40
+ prompt_name: parameters[:prompt_name],
41
+ prompt_label: parameters[:prompt_label],
42
+ prompt_version: parameters[:prompt_version]
43
+ )
44
+ prompt_variables = parameters[:prompt_variables] || {}
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)
53
+
54
+ message.prepend("#{interpolated_prompt} ").strip!
55
+ parameters.merge!(prompt.config) unless parameters[:prompt_config_disabled]
56
+ end
57
+
58
+ prompt
59
+ end
60
+
61
+ def workflow(generation:, message:, audio_file:, parameters:)
62
+ audio_transcription = @base_handler.perform_request(audio_file:, message:, parameters:)
63
+
64
+ audio_file.rewind
65
+ upload_handler = NitroIntelligence::Observability::UploadHandler.new(
66
+ auth_token: @observer.project_client.project.auth_token
67
+ )
68
+ upload_handler.upload(
69
+ generation.trace_id,
70
+ upload_queue: Queue.new([NitroIntelligence::Audio.new(audio_file)])
71
+ )
72
+
73
+ trace_attributes = {
74
+ model: parameters[:model], # Model isn't in response object OpenAI::Models::Audio::Transcription
75
+ input: message,
76
+ output: audio_transcription.text,
77
+ usage_details: {
78
+ input_tokens: audio_transcription.usage.input_tokens,
79
+ output_tokens: audio_transcription.usage.output_tokens,
80
+ total_tokens: audio_transcription.usage.total_tokens,
81
+ },
82
+ }
83
+
84
+ [audio_transcription, trace_attributes]
85
+ end
86
+ end
87
+ end
88
+ end
89
+ end
90
+ end