nitro_intelligence 2.2.0 → 2.4.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: 1181b17cc855ffa2f039b1401fd5dd2040c262d2878e6d265e65f9a581f6106e
4
- data.tar.gz: 28b3a022b8d6d7e8e8472930fcf089fa5a3ead8a6c0d917e7587a73b69b8fba5
3
+ metadata.gz: b9041d12c07ae072e5a7a5a2c5fccbec275d260dfd2c99cba7a5bc92337eab4d
4
+ data.tar.gz: 496aed9a03587308b2af556fa846686edfee3e3a1aaf4a7a3a686cf4f08b322b
5
5
  SHA512:
6
- metadata.gz: e5fd5911aa6740d71f9dc5564766fe80137d7c2da6e7195ee406649387a22a8387bc02db78671e643d90445c6b8578e2b7b47d7573d4abdf677b35edcc41c6a3
7
- data.tar.gz: d11585c4f9731911910660e67eb8702634475fd7bbf523b7313c81910d2775852f918a789a669719840196d64c474c1452842f6948e7618563ee1d5b2a1cd5e1
6
+ metadata.gz: ffa988435a0128d981e79a94f28530d1525b82c231f44e6a3cd87f15d459bcdd27ad2ef19b57499153a0a583256bb7bec2c711f51bd3cf519d9c9827cb080937
7
+ data.tar.gz: 18e594810e1c4815f759375f4692cb726dbc5415c00495aa03b3943d16c77d0079aa115f5aeb9ed368d715429aaafff6967f986c4b1496984160c7cb95c8aeea
data/docs/README.md CHANGED
@@ -31,33 +31,33 @@ NitroIntelligence.configure do |config|
31
31
  },
32
32
  ]
33
33
 
34
- # Agent server settings (optional)
35
- config.agent_server_config = {} # Hash of AgentServer keyword arguments
34
+ # Nitro Intelligence Assistants settings (optional)
35
+ config.assistants_config = {} # Hash of Assistants keyword arguments
36
36
 
37
37
  # Model configuration
38
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",
39
+ "default_audio_transcription_model" => "Qwen/Qwen3-ASR-1.7B",
40
+ "default_text_model" => "Qwen/Qwen3.6-35B-A3B",
41
+ "default_image_model" => "google/gemini-3.1-flash-image",
42
+ "default_text_to_speech_model" => "openai/gpt-4o-mini-tts",
43
43
  "models" => [
44
44
  {
45
- "name" => "gpt-4o-mini",
45
+ "name" => "Qwen/Qwen3.6-35B-A3B",
46
46
  "type" => "text"
47
47
  },
48
48
  {
49
- "name" => "gpt-4o-transcribe",
49
+ "name" => "Qwen/Qwen3-ASR-1.7B",
50
50
  "type" => "audio_transcription"
51
51
  },
52
52
  {
53
- "name" => "nano-banana-2",
53
+ "name" => "google/gemini-3.1-flash-image",
54
54
  "type" => "image",
55
- "aspect_ratios" => ["1:1", "2:3", "3:2", "3:4", "4:3"],
55
+ "aspect_ratios" => ["1:1", "2:3", "3:2", "3:4", "4:3", "9:16", "16:9"],
56
56
  "resolutions" => ["512", "1K", "2K"],
57
57
  "omit_output_fields" => ["provider_specific_fields.thought_signatures"]
58
58
  },
59
59
  {
60
- "name" => "gpt-4o-mini-tts",
60
+ "name" => "openai/gpt-4o-mini-tts",
61
61
  "type" => "text_to_speech",
62
62
  "default_voice" => "marin",
63
63
  "default_response_format" => "mp3",
@@ -80,7 +80,7 @@ end
80
80
  | `inference_base_url` | `String` | `""` | Base URL for the LLM inference service |
81
81
  | `observability_base_url` | `String` | `""` | Base URL for the Langfuse observability service |
82
82
  | `observability_projects` | `Array<Hash>` | `[]` | Langfuse project credentials (slug, id, public_key, secret_key) |
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 |
83
+ | `assistants_config` | `Hash` | `{}` | Credentials for `Assistants.new`. Expected keys: `base_url` (String) — HTTP base URL of Nitro Intelligence Assistants; `api_key` (String) — bearer token; `user_id` (String, default: `"default-user"`) — caller identity |
84
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) |
85
85
 
86
86
  ## Basic Usage
@@ -101,7 +101,7 @@ You may also use [`openai-ruby`](https://github.com/openai/openai-ruby) compatib
101
101
 
102
102
  ```ruby
103
103
  client = NitroIntelligence::Client.new
104
- client.chat(parameters: { model: "meta-llama/Llama-3.1-8B-Instruct", messages: [{ role: "user", content: "Why is the sky blue?" }]})
104
+ client.chat(parameters: { model: "Qwen/Qwen3.8-27B", messages: [{ role: "user", content: "Why is the sky blue?" }]})
105
105
  ```
106
106
 
107
107
  #### Providing Parameters
@@ -110,7 +110,7 @@ Parameters such as 'max_tokens' and 'temperature' can be passed in under the `pa
110
110
 
111
111
  ```ruby
112
112
  client = NitroIntelligence::Client.new
113
- client.chat(parameters: { model: "meta-llama/Llama-3.1-8B-Instruct", max_tokens: 1000, temperature: 0.7, messages: [{ role: "user", content: "Why is the sky blue?" }]})
113
+ client.chat(parameters: { model: "Qwen/Qwen3.8-27B", max_tokens: 1000, temperature: 0.7, messages: [{ role: "user", content: "Why is the sky blue?" }]})
114
114
  ```
115
115
 
116
116
  For a full list of supported parameters, see the [API reference here](https://developers.openai.com/api/reference/resources/completions/methods/create).
@@ -330,6 +330,72 @@ client.chat(
330
330
  )
331
331
  ```
332
332
 
333
+ ### Grouping Traces: Sessions and Tags
334
+
335
+ `session_id` groups related traces in the observability platform, and `tags` label
336
+ them for filtering. Both are optional and are only sent when you set them.
337
+
338
+ ```ruby
339
+ client = NitroIntelligence::Client.new(observability_project_slug: "fake-feature-project")
340
+ client.chat(
341
+ message: "why did this deploy fail?",
342
+ parameters: {
343
+ session_id: "deploy-9f2c1ab",
344
+ tags: ["deployment-failure-analyzer"],
345
+ }
346
+ )
347
+ ```
348
+
349
+ ### Correlating With Application Logs and the Inference Gateway
350
+
351
+ Every observed request is correlated across three systems automatically:
352
+
353
+ * **Observability platform → inference gateway.** The trace ID is sent to the
354
+ gateway as `x-litellm-trace-id`, and `metadata` is sent as
355
+ `x-litellm-spend-logs-metadata` (dropped if it exceeds 4KB).
356
+
357
+ The two are independent. Metadata is sent whenever you set it, observed or not,
358
+ so gateway spend can be attributed even without observability. The trace ID is
359
+ sent only on the observed path - it comes from the observation being recorded,
360
+ never from whatever span happens to be active, so a client built without an
361
+ `observability_project_slug` sends none even inside a host application with its
362
+ own OpenTelemetry instrumentation.
363
+ * **Inference gateway → observability platform.** When a request fails, the
364
+ gateway's own request identifier is read from the error response and recorded on
365
+ the observation as `litellm_call_id` metadata.
366
+ * **Application logs → observability platform.** Put whatever your logs are keyed
367
+ by into `metadata` — it lands on the trace *and* in the gateway's spend logs.
368
+
369
+ ```ruby
370
+ client.chat(
371
+ message: "why did this deploy fail?",
372
+ parameters: {
373
+ trace_seed: deploy_url,
374
+ metadata: {
375
+ source: self.class,
376
+ rails_request_id: request_id,
377
+ job_id: job_id,
378
+ },
379
+ }
380
+ )
381
+ ```
382
+
383
+ To log the trace ID from application code, derive it from the same seed with
384
+ `NitroIntelligence::Trace.create_id(seed:)`.
385
+
386
+ #### Failed Requests
387
+
388
+ A request that raises still produces a usable trace. The observation records:
389
+
390
+ * the `input` that was sent (recorded before the request runs)
391
+ * `level: "ERROR"` and a `status_message` carrying the exception class and message
392
+ * `litellm_call_id` metadata when the gateway returned one
393
+
394
+ The exception is then re-raised, so this changes what is observed, not how callers
395
+ handle failures. Image generation is the one exception to input recording: its
396
+ input carries base64 payloads that are replaced with media references only on
397
+ success, so it records no input up front.
398
+
333
399
  ### Scoring
334
400
 
335
401
  You can use `NitroIntelligence::Reporter` to evaluate existing traces. Calling `NitroIntelligence::Reporter#score` lets you attach metrics to a trace in the observability platform.
@@ -380,18 +446,18 @@ Consider this prompt config:
380
446
 
381
447
  ```json
382
448
  {
383
- "model": "gpt-4o-mini"
449
+ "model": "Qwen/Qwen3.6-35B-A3B"
384
450
  }
385
451
  ```
386
452
 
387
- Invoking this request would result in "gpt-4o-mini" being used as the model, even if supplied manually:
453
+ Invoking this request would result in "Qwen/Qwen3.6-35B-A3B" being used as the model, even if supplied manually:
388
454
 
389
455
  ```ruby
390
456
  client = NitroIntelligence::Client.new(observability_project_slug: "fake-feature-project")
391
457
  client.chat(
392
458
  message: "Where is the appointment?",
393
459
  parameters: {
394
- model: "meta-llama/Llama-3.1-8B-Instruct", # Will not be used, will be overridden by config "gpt-4o-mini"
460
+ model: "Qwen/Qwen3.8-27B", # Will not be used, will be overridden by config "Qwen/Qwen3.6-35B-A3B"
395
461
  prompt_name: "My Prompt With Variables",
396
462
  prompt_variables: {
397
463
  appointment_id: "1234",
@@ -410,7 +476,7 @@ client = NitroIntelligence::Client.new(observability_project_slug: "fake-feature
410
476
  client.chat(
411
477
  message: "Where is the appointment?",
412
478
  parameters: {
413
- model: "meta-llama/Llama-3.1-8B-Instruct", # This will now be used since "prompt_config_disabled" is true
479
+ model: "Qwen/Qwen3.8-27B", # This will now be used since "prompt_config_disabled" is true
414
480
  prompt_name: "My Prompt With Variables",
415
481
  prompt_variables: {
416
482
  appointment_id: "1234",
@@ -423,8 +489,8 @@ client.chat(
423
489
  )
424
490
  ```
425
491
 
426
- ## Agent Server
492
+ ## Nitro Intelligence Assistants
427
493
 
428
- 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.
494
+ `NitroIntelligence::Assistants` 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.
429
495
 
430
- For the full Agent Server guide, see [AGENT_SERVER.md](AGENT_SERVER.md).
496
+ For the full guide, see [ASSISTANTS.md](ASSISTANTS.md). For the service this SDK talks to, see the [Nitro Intelligence Assistants documentation](https://portal.powerapp.cloud/docs/default/system/nip-assistants).
@@ -1,272 +1,12 @@
1
- require "json"
2
- require "net/http"
3
- require "uri"
4
- require "nitro_intelligence/tool_call_review_validator"
1
+ require "active_support/deprecation/constant_accessor"
2
+ require "nitro_intelligence/assistants"
3
+ require "nitro_intelligence/deprecation"
5
4
 
6
5
  module NitroIntelligence
7
- class AgentServer
8
- class ConfigurationError < StandardError; end
9
- class ThreadInitializationError < StandardError; end
10
- class RunError < StandardError; end
11
- class ThreadResumptionError < StandardError; end
6
+ include ActiveSupport::Deprecation::DeprecatedConstantAccessor
12
7
 
13
- # Aegra answers with a conflict when `ifExists: "raise"` is sent for a thread that already exists.
14
- THREAD_CONFLICT_CODE = 409
15
-
16
- attr_reader :base_url, :user_id
17
-
18
- def initialize(base_url:, api_key:, user_id: "default-user")
19
- raise ConfigurationError, "base_url is required" if base_url.blank?
20
- raise ConfigurationError, "api_key is required" if api_key.blank?
21
- raise ConfigurationError, "user_id is required" if user_id.blank?
22
-
23
- @base_url = base_url
24
- @api_key = api_key
25
- @user_id = user_id
26
- @tool_call_review_validator = ToolCallReviewValidator.new
27
- @graph_ids = {}
28
- end
29
-
30
- def await_run(thread_id:, assistant_id:, messages:, context: {})
31
- raise RunError, "messages cannot be empty" if messages.blank?
32
-
33
- initial_state = messages[0..-2]
34
- last_message = messages.last
35
-
36
- initialize_thread_if_needed(thread_id:, assistant_id:, initial_state:)
37
- trigger_run(thread_id:, assistant_id:, context:, last_message:)
38
- end
39
-
40
- def tool_calls_pending_review(thread_id:)
41
- thread_state = get_thread_state(thread_id:)
42
- messages = thread_messages(thread_state)
43
- reviewed_tool_call_ids = tool_messages(messages).map { |message| message["tool_call_id"] }
44
-
45
- messages.each_with_index.flat_map do |message, index|
46
- next [] unless message["type"] == "ai"
47
-
48
- pending_tool_calls(message, reviewed_tool_call_ids).map do |tool_call|
49
- {
50
- "previous_message_id" => index.zero? ? nil : messages[index - 1]&.dig("id"),
51
- "id" => tool_call["id"],
52
- "name" => tool_call["name"],
53
- "args" => tool_call["args"] || {},
54
- }
55
- end
56
- end
57
- end
58
-
59
- def review_tool_calls(thread_id:, assistant_id:, reviewer_id:, tool_calls:, reviewed_at: DateTime.current.iso8601)
60
- resume = { reviewer_id:, reviewed_at:, tool_calls: }.with_indifferent_access
61
- thread = get_thread(thread_id:)
62
- raise ThreadResumptionError, "Thread #{thread_id} is not in the interrupted state" unless interrupted?(thread)
63
-
64
- thread_state = get_thread_state(thread_id:)
65
-
66
- @tool_call_review_validator.validate!(
67
- thread_state:,
68
- tool_calls: resume[:tool_calls],
69
- pending_tool_calls: tool_calls_pending_review(thread_id:)
70
- )
71
-
72
- resume_run(
73
- thread_id:,
74
- assistant_id:,
75
- resume:,
76
- context: interrupt_context(thread_state)
77
- )
78
-
79
- nil
80
- end
81
-
82
- private
83
-
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(
116
- path: "/threads",
117
- body: {
118
- threadId: thread_id.to_s,
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) },
122
- user_id:,
123
- }
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
- )
148
-
149
- raise ThreadInitializationError, state_response.body if state_response.code.to_i != 200
150
-
151
- JSON.parse(state_response.body)
152
- end
153
-
154
- def thread_already_exists?(response)
155
- response.code.to_i == THREAD_CONFLICT_CODE
156
- end
157
-
158
- def get_thread_state(thread_id:)
159
- state_response = get(path: "/threads/#{thread_id}/state")
160
-
161
- raise ThreadResumptionError, state_response.body if state_response.code.to_i != 200
162
-
163
- JSON.parse(state_response.body)
164
- end
165
-
166
- def get_thread(thread_id:)
167
- thread_response = get(path: "/threads/#{thread_id}")
168
-
169
- raise ThreadResumptionError, thread_response.body if thread_response.code.to_i != 200
170
-
171
- JSON.parse(thread_response.body)
172
- end
173
-
174
- def trigger_run(thread_id:, assistant_id:, last_message:, context: {})
175
- run_response = post(
176
- path: "/threads/#{thread_id}/runs/wait",
177
- body: {
178
- assistant_id:,
179
- context:,
180
- input: {
181
- messages: [last_message],
182
- },
183
- }
184
- )
185
-
186
- raise RunError, run_response.body if run_response.code.to_i != 200
187
-
188
- run = JSON.parse(run_response.body)
189
- Array(run["messages"]).last&.dig("content")
190
- end
191
-
192
- def resume_run(thread_id:, assistant_id:, resume:, context:)
193
- run_response = post(
194
- path: "/threads/#{thread_id}/runs/wait",
195
- body: {
196
- assistant_id:,
197
- command: {
198
- resume:,
199
- },
200
- context:,
201
- }
202
- )
203
-
204
- raise ThreadResumptionError, run_response.body if run_response.code.to_i != 200
205
-
206
- JSON.parse(run_response.body)
207
- end
208
-
209
- def interrupted?(thread)
210
- thread["status"] == "interrupted"
211
- end
212
-
213
- def interrupt_context(thread_state)
214
- thread_state.dig("interrupts", 0, "value", "context") || {}
215
- end
216
-
217
- def thread_messages(thread_state)
218
- Array(thread_state.dig("values", "messages"))
219
- end
220
-
221
- def tool_messages(messages)
222
- messages.select { |message| message["type"] == "tool" }
223
- end
224
-
225
- def pending_tool_calls(message, reviewed_tool_call_ids)
226
- Array(message["tool_calls"]).reject do |tool_call|
227
- reviewed_tool_call_ids.include?(tool_call["id"])
228
- end
229
- end
230
-
231
- def get(path:)
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)
251
- end
252
-
253
- def post(path:, body:)
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)
263
- end
264
-
265
- def request_headers
266
- {
267
- "Content-Type" => "application/json",
268
- "Authorization" => "Bearer #{@api_key}",
269
- }
270
- end
271
- end
8
+ # Deprecated: the agent server is now Nitro Intelligence Assistants. Resolved through
9
+ # `const_missing` so that the old name returns the real class rather than a stand-in for it,
10
+ # keeping `is_a?`, `===`, `rescue` and the nested error constants working on upgrade.
11
+ deprecate_constant :AgentServer, "NitroIntelligence::Assistants", deprecator:
272
12
  end
@@ -0,0 +1,287 @@
1
+ require "json"
2
+ require "net/http"
3
+ require "uri"
4
+ require "nitro_intelligence/tool_call_review_validator"
5
+
6
+ module NitroIntelligence
7
+ class Assistants
8
+ class ConfigurationError < StandardError; end
9
+ class ThreadInitializationError < StandardError; end
10
+ class RunError < StandardError; end
11
+ class ThreadResumptionError < StandardError; end
12
+ class ThreadStateError < StandardError; end
13
+
14
+ # Assistants answers with a conflict when `ifExists: "raise"` is sent for a thread that already exists.
15
+ THREAD_CONFLICT_CODE = 409
16
+
17
+ attr_reader :base_url, :user_id
18
+
19
+ def initialize(base_url:, api_key:, user_id: "default-user")
20
+ raise ConfigurationError, "base_url is required" if base_url.blank?
21
+ raise ConfigurationError, "api_key is required" if api_key.blank?
22
+ raise ConfigurationError, "user_id is required" if user_id.blank?
23
+
24
+ @base_url = base_url
25
+ @api_key = api_key
26
+ @user_id = user_id
27
+ @tool_call_review_validator = ToolCallReviewValidator.new
28
+ @graph_ids = {}
29
+ end
30
+
31
+ def await_run(thread_id:, assistant_id:, messages:, context: {})
32
+ raise RunError, "messages cannot be empty" if messages.blank?
33
+
34
+ initial_state = messages[0..-2]
35
+ last_message = messages.last
36
+
37
+ initialize_thread_if_needed(thread_id:, assistant_id:, initial_state:)
38
+ trigger_run(thread_id:, assistant_id:, context:, last_message:)
39
+ end
40
+
41
+ # The thread's state as Assistants reports it, unformatted. Callers that only want the
42
+ # conversation should reach for #thread_messages instead.
43
+ def thread_state(thread_id:)
44
+ get_thread_state(thread_id:, error: ThreadStateError)
45
+ end
46
+
47
+ # The thread's messages as Assistants reports them, unformatted, oldest first. Each message
48
+ # carries its own `type` ("human", "ai", "tool", ...), which callers map to their own roles.
49
+ def thread_messages(thread_id:)
50
+ messages_in(thread_state(thread_id:))
51
+ end
52
+
53
+ def tool_calls_pending_review(thread_id:)
54
+ thread_state = get_thread_state(thread_id:)
55
+ messages = messages_in(thread_state)
56
+ reviewed_tool_call_ids = tool_messages(messages).map { |message| message["tool_call_id"] }
57
+
58
+ messages.each_with_index.flat_map do |message, index|
59
+ next [] unless message["type"] == "ai"
60
+
61
+ pending_tool_calls(message, reviewed_tool_call_ids).map do |tool_call|
62
+ {
63
+ "previous_message_id" => index.zero? ? nil : messages[index - 1]&.dig("id"),
64
+ "id" => tool_call["id"],
65
+ "name" => tool_call["name"],
66
+ "args" => tool_call["args"] || {},
67
+ }
68
+ end
69
+ end
70
+ end
71
+
72
+ def review_tool_calls(thread_id:, assistant_id:, reviewer_id:, tool_calls:, reviewed_at: DateTime.current.iso8601)
73
+ resume = { reviewer_id:, reviewed_at:, tool_calls: }.with_indifferent_access
74
+ thread = get_thread(thread_id:)
75
+ raise ThreadResumptionError, "Thread #{thread_id} is not in the interrupted state" unless interrupted?(thread)
76
+
77
+ thread_state = get_thread_state(thread_id:)
78
+
79
+ @tool_call_review_validator.validate!(
80
+ thread_state:,
81
+ tool_calls: resume[:tool_calls],
82
+ pending_tool_calls: tool_calls_pending_review(thread_id:)
83
+ )
84
+
85
+ resume_run(
86
+ thread_id:,
87
+ assistant_id:,
88
+ resume:,
89
+ context: interrupt_context(thread_state)
90
+ )
91
+
92
+ nil
93
+ end
94
+
95
+ private
96
+
97
+ # Assistants accepts `initial_state` on thread creation but never applies it, so a brand new thread is
98
+ # seeded through the thread state endpoint instead. A thread that already exists is left untouched:
99
+ # its state was seeded when it was created and has been built up by every run since.
100
+ def initialize_thread_if_needed(thread_id:, assistant_id:, initial_state:)
101
+ thread_response = create_thread(thread_id:, assistant_id:)
102
+
103
+ return if thread_already_exists?(thread_response)
104
+ raise ThreadInitializationError, thread_response.body if thread_response.code.to_i != 200
105
+ return if initial_state.blank?
106
+
107
+ seed_new_thread_state(thread_id:, initial_state:)
108
+ end
109
+
110
+ # Creating the thread and seeding its state are separate requests, so a failure between them leaves
111
+ # an empty thread behind. A retry would find that thread, take it for one already under way, skip
112
+ # seeding and run without the history -- losing the very thing seeding exists for, without an error.
113
+ # Discard the thread instead, so a retry starts over from a clean slate.
114
+ def seed_new_thread_state(thread_id:, initial_state:)
115
+ seed_thread_state(thread_id:, initial_state:)
116
+ rescue
117
+ discard_thread(thread_id:)
118
+ raise
119
+ end
120
+
121
+ def discard_thread(thread_id:)
122
+ delete(path: "/threads/#{thread_id}")
123
+ rescue
124
+ # Best effort. The seeding failure is the one worth surfacing, and it is raised either way.
125
+ end
126
+
127
+ def create_thread(thread_id:, assistant_id:)
128
+ post(
129
+ path: "/threads",
130
+ body: {
131
+ threadId: thread_id.to_s,
132
+ ifExists: "raise",
133
+ # Without a graph_id, the thread state cannot be updated before the thread's first run.
134
+ metadata: { graph_id: graph_id_for(assistant_id) },
135
+ user_id:,
136
+ }
137
+ )
138
+ end
139
+
140
+ def graph_id_for(assistant_id)
141
+ @graph_ids[assistant_id] ||= fetch_graph_id(assistant_id)
142
+ end
143
+
144
+ def fetch_graph_id(assistant_id)
145
+ assistant_response = get(path: "/assistants/#{assistant_id}")
146
+
147
+ raise ThreadInitializationError, assistant_response.body if assistant_response.code.to_i != 200
148
+
149
+ graph_id = JSON.parse(assistant_response.body)["graph_id"]
150
+
151
+ raise ThreadInitializationError, "Assistant #{assistant_id} has no graph_id" if graph_id.blank?
152
+
153
+ graph_id
154
+ end
155
+
156
+ def seed_thread_state(thread_id:, initial_state:)
157
+ state_response = post(
158
+ path: "/threads/#{thread_id}/state",
159
+ body: { values: { messages: initial_state } }
160
+ )
161
+
162
+ raise ThreadInitializationError, state_response.body if state_response.code.to_i != 200
163
+
164
+ JSON.parse(state_response.body)
165
+ end
166
+
167
+ def thread_already_exists?(response)
168
+ response.code.to_i == THREAD_CONFLICT_CODE
169
+ end
170
+
171
+ # The review flows have always raised ThreadResumptionError when a state read fails, and consumers
172
+ # rescue it as such. A plain read resumes nothing, so #thread_state asks for ThreadStateError.
173
+ def get_thread_state(thread_id:, error: ThreadResumptionError)
174
+ state_response = get(path: "/threads/#{thread_id}/state")
175
+
176
+ raise error, state_response.body if state_response.code.to_i != 200
177
+
178
+ JSON.parse(state_response.body)
179
+ end
180
+
181
+ def get_thread(thread_id:)
182
+ thread_response = get(path: "/threads/#{thread_id}")
183
+
184
+ raise ThreadResumptionError, thread_response.body if thread_response.code.to_i != 200
185
+
186
+ JSON.parse(thread_response.body)
187
+ end
188
+
189
+ def trigger_run(thread_id:, assistant_id:, last_message:, context: {})
190
+ run_response = post(
191
+ path: "/threads/#{thread_id}/runs/wait",
192
+ body: {
193
+ assistant_id:,
194
+ context:,
195
+ input: {
196
+ messages: [last_message],
197
+ },
198
+ }
199
+ )
200
+
201
+ raise RunError, run_response.body if run_response.code.to_i != 200
202
+
203
+ run = JSON.parse(run_response.body)
204
+ Array(run["messages"]).last&.dig("content")
205
+ end
206
+
207
+ def resume_run(thread_id:, assistant_id:, resume:, context:)
208
+ run_response = post(
209
+ path: "/threads/#{thread_id}/runs/wait",
210
+ body: {
211
+ assistant_id:,
212
+ command: {
213
+ resume:,
214
+ },
215
+ context:,
216
+ }
217
+ )
218
+
219
+ raise ThreadResumptionError, run_response.body if run_response.code.to_i != 200
220
+
221
+ JSON.parse(run_response.body)
222
+ end
223
+
224
+ def interrupted?(thread)
225
+ thread["status"] == "interrupted"
226
+ end
227
+
228
+ def interrupt_context(thread_state)
229
+ thread_state.dig("interrupts", 0, "value", "context") || {}
230
+ end
231
+
232
+ def messages_in(thread_state)
233
+ Array(thread_state.dig("values", "messages"))
234
+ end
235
+
236
+ def tool_messages(messages)
237
+ messages.select { |message| message["type"] == "tool" }
238
+ end
239
+
240
+ def pending_tool_calls(message, reviewed_tool_call_ids)
241
+ Array(message["tool_calls"]).reject do |tool_call|
242
+ reviewed_tool_call_ids.include?(tool_call["id"])
243
+ end
244
+ end
245
+
246
+ def get(path:)
247
+ uri = URI("#{base_url}#{path}")
248
+ http = Net::HTTP.new(uri.host, uri.port)
249
+ http.use_ssl = uri.scheme == "https"
250
+
251
+ request = Net::HTTP::Get.new(uri)
252
+ request_headers.each { |k, v| request[k] = v }
253
+
254
+ http.request(request)
255
+ end
256
+
257
+ def delete(path:)
258
+ uri = URI("#{base_url}#{path}")
259
+ http = Net::HTTP.new(uri.host, uri.port)
260
+ http.use_ssl = uri.scheme == "https"
261
+
262
+ request = Net::HTTP::Delete.new(uri)
263
+ request_headers.each { |k, v| request[k] = v }
264
+
265
+ http.request(request)
266
+ end
267
+
268
+ def post(path:, body:)
269
+ uri = URI("#{base_url}#{path}")
270
+ http = Net::HTTP.new(uri.host, uri.port)
271
+ http.use_ssl = uri.scheme == "https"
272
+
273
+ request = Net::HTTP::Post.new(uri)
274
+ request_headers.each { |k, v| request[k] = v }
275
+ request.body = body.to_json
276
+
277
+ http.request(request)
278
+ end
279
+
280
+ def request_headers
281
+ {
282
+ "Content-Type" => "application/json",
283
+ "Authorization" => "Bearer #{@api_key}",
284
+ }
285
+ end
286
+ end
287
+ end
@@ -12,8 +12,9 @@ module NitroIntelligence
12
12
  perform_request(audio_file:, message:, parameters:)
13
13
  end
14
14
 
15
- def perform_request(audio_file:, message: "", parameters: {})
15
+ def perform_request(audio_file:, message: "", parameters: {}, correlation_trace_id: nil)
16
16
  add_request_headers(parameters, MODALITY_HEADER => "audio", REQUESTED_MODEL_HEADER => parameters[:model])
17
+ add_correlation_headers(parameters, trace_id: correlation_trace_id)
17
18
  @client.audio.transcriptions.create(
18
19
  prompt: message,
19
20
  file: audio_file,
@@ -1,3 +1,5 @@
1
+ require "json"
2
+
1
3
  module NitroIntelligence
2
4
  module Client
3
5
  module Handlers
@@ -5,6 +7,15 @@ module NitroIntelligence
5
7
  MODALITY_HEADER = "nip-modality".freeze
6
8
  REQUESTED_MODEL_HEADER = "nip-requested-model".freeze
7
9
 
10
+ # Correlation headers understood by the inference gateway (LiteLLM).
11
+ # See https://docs.litellm.ai/docs/proxy/request_headers
12
+ TRACE_ID_HEADER = "x-litellm-trace-id".freeze
13
+ SPEND_LOGS_METADATA_HEADER = "x-litellm-spend-logs-metadata".freeze
14
+
15
+ # Headers over ~8KB are rejected by most proxies. Metadata is caller
16
+ # supplied, so cap it rather than turning a large hash into a failed request.
17
+ MAX_SPEND_LOGS_METADATA_BYTES = 4096
18
+
8
19
  def initialize(client:)
9
20
  @client = client
10
21
  end
@@ -16,6 +27,41 @@ module NitroIntelligence
16
27
  (request_options[:extra_headers] ||= {}).merge!(headers.compact)
17
28
  parameters
18
29
  end
30
+
31
+ # Hands the inference gateway what it needs to be matched up with the rest of
32
+ # the picture: the trace ID the observability platform is recording this
33
+ # request under, so a Langfuse trace can be found from a LiteLLM request even
34
+ # when the request fails and never produces a response body, and the caller's
35
+ # metadata, so gateway spend can be attributed to the work that caused it.
36
+ #
37
+ # The two are independent. Metadata is worth sending whether or not anything
38
+ # is observing, whereas the trace ID is supplied by the observed handlers and
39
+ # is never read from whatever tracing context happens to be active: a host
40
+ # application with its own instrumentation has traces of its own, and their
41
+ # IDs mean nothing to the observability platform.
42
+ #
43
+ # add_request_headers drops nil values, so each header appears only when it
44
+ # has something to say.
45
+ def add_correlation_headers(parameters, trace_id:)
46
+ add_request_headers(
47
+ parameters,
48
+ TRACE_ID_HEADER => trace_id.presence,
49
+ SPEND_LOGS_METADATA_HEADER => spend_logs_metadata(parameters[:metadata])
50
+ )
51
+ end
52
+
53
+ def spend_logs_metadata(metadata)
54
+ return nil if metadata.blank?
55
+
56
+ json = metadata.to_json
57
+ return json if json.bytesize <= MAX_SPEND_LOGS_METADATA_BYTES
58
+
59
+ NitroIntelligence.logger.warn(
60
+ "#{self.class} metadata is #{json.bytesize} bytes, over the " \
61
+ "#{MAX_SPEND_LOGS_METADATA_BYTES} byte #{SPEND_LOGS_METADATA_HEADER} limit - omitting it"
62
+ )
63
+ nil
64
+ end
19
65
  end
20
66
  end
21
67
  end
@@ -12,8 +12,9 @@ module NitroIntelligence
12
12
  perform_request(parameters:)
13
13
  end
14
14
 
15
- def perform_request(parameters: {})
15
+ def perform_request(parameters: {}, correlation_trace_id: nil)
16
16
  add_request_headers(parameters, REQUESTED_MODEL_HEADER => parameters[:model])
17
+ add_correlation_headers(parameters, trace_id: correlation_trace_id)
17
18
  @client.chat.completions.create(**parameters.slice(*ALLOWED_EXTRA_PARAMETERS))
18
19
  end
19
20
 
@@ -19,8 +19,9 @@ module NitroIntelligence
19
19
  image_generation
20
20
  end
21
21
 
22
- def perform_request(parameters: {})
22
+ def perform_request(parameters: {}, correlation_trace_id: nil)
23
23
  add_request_headers(parameters, MODALITY_HEADER => "image", REQUESTED_MODEL_HEADER => parameters[:model])
24
+ add_correlation_headers(parameters, trace_id: correlation_trace_id)
24
25
  @client.chat.completions.create(**parameters.slice(*ALLOWED_EXTRA_PARAMETERS))
25
26
  end
26
27
 
@@ -26,7 +26,8 @@ module NitroIntelligence
26
26
  type: :generation,
27
27
  parameters:,
28
28
  trace_name:,
29
- prompt:
29
+ prompt:,
30
+ input: message
30
31
  ) do |generation|
31
32
  workflow(generation:, message:, audio_file:, parameters:)
32
33
  end
@@ -55,7 +56,9 @@ module NitroIntelligence
55
56
  end
56
57
 
57
58
  def workflow(generation:, message:, audio_file:, parameters:)
58
- audio_transcription = @base_handler.perform_request(audio_file:, message:, parameters:)
59
+ audio_transcription = @base_handler.perform_request(
60
+ audio_file:, message:, parameters:, correlation_trace_id: generation.trace_id
61
+ )
59
62
 
60
63
  audio_file.rewind
61
64
  upload_handler = NitroIntelligence::Observability::UploadHandler.new(
@@ -66,9 +69,10 @@ module NitroIntelligence
66
69
  upload_queue: Queue.new([NitroIntelligence::Audio.new(audio_file)])
67
70
  )
68
71
 
72
+ # The model and input are already recorded on the observation before the
73
+ # request runs, and the response carries no model of its own
74
+ # (OpenAI::Models::Audio::Transcription), so neither is set again here.
69
75
  trace_attributes = {
70
- model: parameters[:model], # Model isn't in response object OpenAI::Models::Audio::Transcription
71
- input: message,
72
76
  output: audio_transcription.text,
73
77
  usage_details: {
74
78
  input_tokens: audio_transcription.usage.input_tokens,
@@ -21,9 +21,10 @@ module NitroIntelligence
21
21
  type: :generation,
22
22
  parameters:,
23
23
  trace_name:,
24
- prompt:
25
- ) do |_generation|
26
- workflow(parameters:)
24
+ prompt:,
25
+ input: parameters[:messages]
26
+ ) do |generation|
27
+ workflow(generation:, parameters:)
27
28
  end
28
29
  end
29
30
 
@@ -46,14 +47,12 @@ module NitroIntelligence
46
47
  prompt
47
48
  end
48
49
 
49
- def workflow(parameters:)
50
- chat_completion = @base_handler.perform_request(parameters:)
51
- input = parameters[:messages]
50
+ def workflow(generation:, parameters:)
51
+ chat_completion = @base_handler.perform_request(parameters:, correlation_trace_id: generation.trace_id)
52
52
  output = chat_completion.choices.first.message.to_h
53
53
 
54
54
  trace_attributes = {
55
55
  model: chat_completion.model,
56
- input:,
57
56
  output:,
58
57
  usage_details: {
59
58
  prompt_tokens: chat_completion.usage.prompt_tokens,
@@ -76,7 +76,7 @@ module NitroIntelligence
76
76
  end
77
77
 
78
78
  def workflow(generation:, image_generation:, parameters:)
79
- chat_completion = @base_handler.perform_request(parameters:)
79
+ chat_completion = @base_handler.perform_request(parameters:, correlation_trace_id: generation.trace_id)
80
80
 
81
81
  image_generation.trace_id = generation.trace_id
82
82
  image_generation.parse_file(chat_completion)
@@ -25,7 +25,8 @@ module NitroIntelligence
25
25
  type: :generation,
26
26
  parameters:,
27
27
  trace_name:,
28
- prompt:
28
+ prompt:,
29
+ input: message
29
30
  ) do |generation|
30
31
  workflow(message:, parameters:, trace_id: generation.trace_id)
31
32
  end
@@ -67,7 +68,7 @@ module NitroIntelligence
67
68
  end
68
69
 
69
70
  def workflow(message:, parameters:, trace_id:)
70
- tts = @base_handler.perform_request(message:, parameters:)
71
+ tts = @base_handler.perform_request(message:, parameters:, correlation_trace_id: trace_id)
71
72
  output = ""
72
73
 
73
74
  Tempfile.create(["tts", ".#{parameters[:response_format]}"]) do |tempfile|
@@ -78,12 +79,10 @@ module NitroIntelligence
78
79
  output = handle_text_to_speech_upload(tempfile, trace_id)
79
80
  end
80
81
 
81
- # We only get StringIO object as a response
82
- # We dont have usage on tokens and the actual model that was used
83
- # We will log the requested model instead
82
+ # We only get StringIO object as a response, so there are no usage details
83
+ # and no resolved model to record. The requested model and the input are
84
+ # already on the observation from before the request ran.
84
85
  trace_attributes = {
85
- model: parameters[:model],
86
- input: message,
87
86
  output:,
88
87
  }
89
88
 
@@ -12,8 +12,9 @@ module NitroIntelligence
12
12
  perform_request(message:, parameters:)
13
13
  end
14
14
 
15
- def perform_request(message: "", parameters: {})
15
+ def perform_request(message: "", parameters: {}, correlation_trace_id: nil)
16
16
  add_request_headers(parameters, MODALITY_HEADER => "audio", REQUESTED_MODEL_HEADER => parameters[:model])
17
+ add_correlation_headers(parameters, trace_id: correlation_trace_id)
17
18
  @client.audio.speech.create(
18
19
  input: message,
19
20
  **parameters.slice(*ALLOWED_EXTRA_PARAMETERS)
@@ -2,16 +2,23 @@ module NitroIntelligence
2
2
  module Client
3
3
  module Observers
4
4
  class LangfuseObserver
5
+ # The inference gateway returns its own request identifier on every
6
+ # response, including error responses. Recording it on the observation is
7
+ # the only way to line a failed generation up with the gateway's logs.
8
+ # See https://docs.litellm.ai/docs/proxy/response_headers
9
+ LITELLM_CALL_ID_HEADER = "x-litellm-call-id".freeze
10
+
11
+ MAX_STATUS_MESSAGE_LENGTH = 2000
12
+
5
13
  attr_reader :project_client
6
14
 
7
15
  def initialize(project_client:)
8
16
  @project_client = project_client
9
17
  end
10
18
 
11
- def observe(operation_name, type:, parameters:, trace_name:, prompt: nil) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
19
+ def observe(operation_name, type:, parameters:, trace_name:, prompt: nil, input: nil) # rubocop:disable Metrics/AbcSize
12
20
  metadata = parameters[:metadata]
13
21
  seed = parameters[:trace_seed]
14
- user_id = parameters[:user_id] || NitroIntelligence.configuration.observability_user_id
15
22
  trace_id = NitroIntelligence::Trace.create_id(seed:) if seed.present?
16
23
 
17
24
  if prompt
@@ -21,10 +28,7 @@ module NitroIntelligence
21
28
 
22
29
  metadata = metadata.transform_values(&:to_s)
23
30
 
24
- Langfuse.propagate_attributes(
25
- user_id:,
26
- metadata:
27
- ) do
31
+ Langfuse.propagate_attributes(**propagated_attributes(parameters, metadata)) do
28
32
  @project_client.observability_client.observe(
29
33
  operation_name,
30
34
  as_type: type,
@@ -35,8 +39,9 @@ module NitroIntelligence
35
39
  ) do |generation|
36
40
  generation.update_trace(name: trace_name, release: NitroIntelligence.configuration.current_revision)
37
41
  generation.update({ prompt: { name: prompt.name, version: prompt.version } }) if prompt
42
+ record_input(generation, input)
38
43
 
39
- result, trace_attributes = yield(generation)
44
+ result, trace_attributes = observe_failures(generation) { yield(generation) }
40
45
 
41
46
  if trace_attributes
42
47
  handle_truncation(trace_attributes[:input], trace_attributes[:output], trace_attributes[:model])
@@ -56,6 +61,56 @@ module NitroIntelligence
56
61
 
57
62
  private
58
63
 
64
+ # Recorded before the request is made so that a request which raises still
65
+ # shows what was sent. Handlers whose input is not safe to record twice
66
+ # (image generation sends base64 payloads that are replaced with media
67
+ # references on success) pass no input and rely on the failure record alone.
68
+ def record_input(generation, input)
69
+ return if input.blank?
70
+
71
+ generation.input = input
72
+ generation.update_trace(input:)
73
+ end
74
+
75
+ # `session_id` and `tags` are only forwarded when set so that callers who
76
+ # pass neither keep the existing propagation payload.
77
+ def propagated_attributes(parameters, metadata)
78
+ attributes = {
79
+ user_id: parameters[:user_id] || NitroIntelligence.configuration.observability_user_id,
80
+ metadata:,
81
+ }
82
+ attributes[:session_id] = parameters[:session_id] if parameters[:session_id].present?
83
+ attributes[:tags] = parameters[:tags] if parameters[:tags].present?
84
+ attributes
85
+ end
86
+
87
+ # Without this, a request that raises leaves an observation carrying only
88
+ # its name and model - no input, no output, no indication anything went
89
+ # wrong - because langfuse-rb ends the span in an `ensure` and never
90
+ # records the exception.
91
+ def observe_failures(generation)
92
+ yield
93
+ rescue => e
94
+ record_failure(generation, e)
95
+ raise
96
+ end
97
+
98
+ def record_failure(generation, error)
99
+ generation.update(
100
+ level: "ERROR",
101
+ status_message: "#{error.class}: #{error.message}".truncate(MAX_STATUS_MESSAGE_LENGTH)
102
+ )
103
+
104
+ call_id = litellm_call_id(error)
105
+ generation.metadata = { litellm_call_id: call_id } if call_id
106
+ end
107
+
108
+ def litellm_call_id(error)
109
+ return nil unless error.respond_to?(:headers)
110
+
111
+ error.headers&.[](LITELLM_CALL_ID_HEADER)
112
+ end
113
+
59
114
  def handle_truncation(_input, output, model_name)
60
115
  model = NitroIntelligence.model_catalog.lookup_by_name(model_name)
61
116
 
@@ -10,7 +10,7 @@ module NitroIntelligence
10
10
  config_accessor :cache_provider, default: NitroIntelligence::NullCache.new
11
11
  config_accessor :current_revision, default: ""
12
12
  config_accessor :environment, default: "test"
13
- config_accessor :agent_server_config, default: {}
13
+ config_accessor :assistants_config, default: {}
14
14
  config_accessor :inference_api_key, default: ""
15
15
  config_accessor :inference_base_url, default: ""
16
16
  config_accessor :model_config, default: {}
@@ -18,6 +18,10 @@ module NitroIntelligence
18
18
  config_accessor :observability_projects, default: []
19
19
  config_accessor :observability_user_id, default: ""
20
20
 
21
+ # Deprecated: configure `assistants_config` instead. Keeps its original `{}` default through the
22
+ # deprecation window, so a host building the hash up in place still has one to build on.
23
+ config_accessor :agent_server_config, default: {}
24
+
21
25
  class << self
22
26
  def configure
23
27
  yield config
@@ -0,0 +1,11 @@
1
+ require "active_support/deprecation"
2
+
3
+ module NitroIntelligence
4
+ class << self
5
+ # Deprecations introduced while the agent server was renamed to Nitro Intelligence Assistants.
6
+ # The names they cover are removed in 3.0.
7
+ def deprecator
8
+ @deprecator ||= ActiveSupport::Deprecation.new("3.0", "Nitro Intelligence")
9
+ end
10
+ end
11
+ end
@@ -63,7 +63,7 @@ module NitroIntelligence
63
63
  end
64
64
 
65
65
  def raise_error!(message)
66
- raise NitroIntelligence::AgentServer::ThreadResumptionError, message
66
+ raise NitroIntelligence::Assistants::ThreadResumptionError, message
67
67
  end
68
68
  end
69
69
  end
@@ -1,3 +1,3 @@
1
1
  module NitroIntelligence
2
- VERSION = "2.2.0".freeze
2
+ VERSION = "2.4.0".freeze
3
3
  end
@@ -7,9 +7,11 @@ require "openai"
7
7
 
8
8
  require "nitro_intelligence/version"
9
9
  require "nitro_intelligence/agent_server"
10
+ require "nitro_intelligence/assistants"
10
11
  require "nitro_intelligence/client/base"
11
12
  require "nitro_intelligence/client/client"
12
13
  require "nitro_intelligence/configuration"
14
+ require "nitro_intelligence/deprecation"
13
15
  require "nitro_intelligence/media/image_generation"
14
16
  require "nitro_intelligence/models/model_catalog"
15
17
  require "nitro_intelligence/observability/project_client_registry"
@@ -21,8 +23,14 @@ module NitroIntelligence
21
23
  class << self
22
24
  delegate :configure, :config, :logger, :environment, to: :configuration
23
25
 
26
+ def assistants
27
+ Assistants.new(**assistants_config.symbolize_keys)
28
+ end
29
+
30
+ # Deprecated: use `NitroIntelligence.assistants`.
24
31
  def agent_server
25
- AgentServer.new(**configuration.agent_server_config.symbolize_keys)
32
+ deprecator.warn("`NitroIntelligence.agent_server` is deprecated. Use `NitroIntelligence.assistants` instead.")
33
+ assistants
26
34
  end
27
35
 
28
36
  def cache
@@ -38,5 +46,20 @@ module NitroIntelligence
38
46
  base_url: configuration.observability_base_url
39
47
  )
40
48
  end
49
+
50
+ private
51
+
52
+ # A host that has migrated is left alone, so a stale `agent_server_config` cannot override the
53
+ # configuration it was replaced by.
54
+ def assistants_config
55
+ current_config = configuration.assistants_config
56
+ legacy_config = configuration.agent_server_config
57
+ return current_config if current_config.present? || legacy_config.blank?
58
+
59
+ deprecator.warn(
60
+ "`agent_server_config` is deprecated. Configure `assistants_config` instead."
61
+ )
62
+ legacy_config
63
+ end
41
64
  end
42
65
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: nitro_intelligence
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.2.0
4
+ version: 2.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Igor Artemenko
@@ -76,6 +76,7 @@ files:
76
76
  - docs/README.md
77
77
  - lib/nitro_intelligence.rb
78
78
  - lib/nitro_intelligence/agent_server.rb
79
+ - lib/nitro_intelligence/assistants.rb
79
80
  - lib/nitro_intelligence/client/base.rb
80
81
  - lib/nitro_intelligence/client/client.rb
81
82
  - lib/nitro_intelligence/client/factory.rb
@@ -91,6 +92,7 @@ files:
91
92
  - lib/nitro_intelligence/client/observed.rb
92
93
  - lib/nitro_intelligence/client/observers/langfuse_observer.rb
93
94
  - lib/nitro_intelligence/configuration.rb
95
+ - lib/nitro_intelligence/deprecation.rb
94
96
  - lib/nitro_intelligence/langfuse_extension.rb
95
97
  - lib/nitro_intelligence/langfuse_tracer_provider.rb
96
98
  - lib/nitro_intelligence/media/audio.rb