nitro_intelligence 2.3.0 → 2.5.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: 2c196d3b804d5a9137a2af97b45df17a137382114b2d592d835d3477af0541e6
4
- data.tar.gz: f0fb2d024492cd2f04b52d12f41930fc0ac9ed323aca3681a59016eccc3db7fb
3
+ metadata.gz: 0bc1642f0475d8292a1e9611003d79e31983fb9afe2fd5e35d5ddf821a191e53
4
+ data.tar.gz: f61c77b0a3cd8231ba52bd7df317ca715423dc08eb72639e028febf508d52472
5
5
  SHA512:
6
- metadata.gz: c41e6aa4592a56a75a7a45715ffc2a313f0a1ecf39c227a780334d60b204292790bb715da83783763931da5af95d1e845dffb3addc208329c9dbd01ed2f75d0b
7
- data.tar.gz: 7eb926cd024f0b73c2b372e75d65a43cf51a82f7afd487cd589b61be0f4130549d3fe08c3f9a01965668e092f092a5d431423af925533997f863c88f70c7534c
6
+ metadata.gz: 7603cb32f91bda923f1728c5318db1a1b0e7fb59c1a46bca3a4ac3c01567de9d850b578626dd76f05d17b3348e1cdb5495fa6e98f28a6e9c871323147f02db24
7
+ data.tar.gz: 251976a344210f499e8b7b0c7c05bec38461fd4d941cd852fe1d5b52bc4d69cc052f992b22fe83a0241845b8d8212773f77e2e5a0ea5addcc7c459d4d65bde2f
data/docs/README.md CHANGED
@@ -31,8 +31,8 @@ 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 = {
@@ -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
@@ -489,8 +489,8 @@ client.chat(
489
489
  )
490
490
  ```
491
491
 
492
- ## Agent Server
492
+ ## Nitro Intelligence Assistants
493
493
 
494
- 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.
495
495
 
496
- 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
@@ -16,12 +16,73 @@ module NitroIntelligence
16
16
  # supplied, so cap it rather than turning a large hash into a failed request.
17
17
  MAX_SPEND_LOGS_METADATA_BYTES = 4096
18
18
 
19
+ # Cost the inference gateway calculated for a response.
20
+ # See https://docs.litellm.ai/docs/proxy/response_headers
21
+ #
22
+ # The gateway is the only component that knows which deployment actually
23
+ # served a request, and the same model group can be served by internal
24
+ # capacity or by any of several third-party providers at different rates.
25
+ # Recomputing cost downstream from token counts therefore means maintaining
26
+ # a second price table that silently drifts from the one doing the billing.
27
+ RESPONSE_COST_HEADER = "x-litellm-response-cost".freeze
28
+ RESPONSE_COST_INPUT_HEADER = "x-litellm-response-cost-input".freeze
29
+ RESPONSE_COST_OUTPUT_HEADER = "x-litellm-response-cost-output".freeze
30
+
19
31
  def initialize(client:)
20
32
  @client = client
21
33
  end
22
34
 
35
+ # Cost breakdown for an observation, or nil when the gateway did not price
36
+ # the request.
37
+ #
38
+ # A deployment the gateway has no price for sends no cost header at all
39
+ # rather than a zero one, so absence has to mean "unknown" here. Recording
40
+ # nil leaves the generation without a cost; recording 0.0 would assert the
41
+ # request was free and quietly understate spend for every model still
42
+ # awaiting a price.
43
+ #
44
+ # Only the total is guaranteed. Where the cost comes from the upstream
45
+ # provider rather than the gateway's own calculation - OpenRouter reports a
46
+ # real per-request cost of its own, which the gateway passes through - there
47
+ # is no component breakdown, so input and output appear only when sent.
48
+ def cost_details(response)
49
+ headers = response_headers(response)
50
+ return nil if headers.nil?
51
+
52
+ total = header_amount(headers, RESPONSE_COST_HEADER)
53
+ return nil if total.nil?
54
+
55
+ {
56
+ total:,
57
+ input: header_amount(headers, RESPONSE_COST_INPUT_HEADER),
58
+ output: header_amount(headers, RESPONSE_COST_OUTPUT_HEADER),
59
+ }.compact
60
+ end
61
+
23
62
  private
24
63
 
64
+ # `last_response` carries the HTTP metadata of the response a typed model was
65
+ # built from. The client leaves it unset on nested and locally constructed
66
+ # models, and on endpoints returning raw or binary payloads, so both the
67
+ # method and its value are optional.
68
+ def response_headers(response)
69
+ return nil unless response.respond_to?(:last_response)
70
+
71
+ response.last_response&.headers
72
+ end
73
+
74
+ # Header values arrive as strings. A malformed one is worth ignoring rather
75
+ # than raising: a trace missing its cost is a far smaller problem than an
76
+ # inference call failing because the gateway sent something unexpected.
77
+ def header_amount(headers, name)
78
+ value = headers[name]
79
+ return nil if value.blank?
80
+
81
+ Float(value)
82
+ rescue ArgumentError, TypeError
83
+ nil
84
+ end
85
+
25
86
  def add_request_headers(parameters, headers)
26
87
  request_options = (parameters[:request_options] ||= {})
27
88
  (request_options[:extra_headers] ||= {}).merge!(headers.compact)
@@ -79,6 +79,7 @@ module NitroIntelligence
79
79
  output_tokens: audio_transcription.usage.output_tokens,
80
80
  total_tokens: audio_transcription.usage.total_tokens,
81
81
  },
82
+ cost_details: @base_handler.cost_details(audio_transcription),
82
83
  }
83
84
 
84
85
  [audio_transcription, trace_attributes]
@@ -59,6 +59,7 @@ module NitroIntelligence
59
59
  completion_tokens: chat_completion.usage.completion_tokens,
60
60
  total_tokens: chat_completion.usage.total_tokens,
61
61
  },
62
+ cost_details: @base_handler.cost_details(chat_completion),
62
63
  }
63
64
 
64
65
  [chat_completion, trace_attributes]
@@ -94,6 +94,7 @@ module NitroIntelligence
94
94
  completion_tokens: chat_completion.usage.completion_tokens,
95
95
  total_tokens: chat_completion.usage.total_tokens,
96
96
  },
97
+ cost_details: @base_handler.cost_details(chat_completion),
97
98
  }
98
99
 
99
100
  [chat_completion, trace_attributes]
@@ -42,17 +42,7 @@ module NitroIntelligence
42
42
  record_input(generation, input)
43
43
 
44
44
  result, trace_attributes = observe_failures(generation) { yield(generation) }
45
-
46
- if trace_attributes
47
- handle_truncation(trace_attributes[:input], trace_attributes[:output], trace_attributes[:model])
48
-
49
- generation.model = trace_attributes[:model] if trace_attributes[:model]
50
- generation.usage_details = trace_attributes[:usage_details] if trace_attributes[:usage_details]
51
- generation.input = trace_attributes[:input] if trace_attributes[:input]
52
- generation.output = trace_attributes[:output] if trace_attributes[:output]
53
-
54
- generation.update_trace(input: trace_attributes[:input], output: trace_attributes[:output])
55
- end
45
+ record_result(generation, trace_attributes)
56
46
 
57
47
  result
58
48
  end
@@ -61,6 +51,27 @@ module NitroIntelligence
61
51
 
62
52
  private
63
53
 
54
+ # Applied once the response is in hand, so the observation reflects what came
55
+ # back rather than what was asked for. Each attribute is set only when the
56
+ # handler supplied it: an observation that records nothing is better than one
57
+ # asserting a value the response never carried. Cost is the clearest case -
58
+ # the gateway omits the header entirely for a deployment it has no price for,
59
+ # and writing a zero there would read as a free request rather than an
60
+ # unpriced one.
61
+ def record_result(generation, trace_attributes)
62
+ return unless trace_attributes
63
+
64
+ handle_truncation(trace_attributes[:input], trace_attributes[:output], trace_attributes[:model])
65
+
66
+ generation.model = trace_attributes[:model] if trace_attributes[:model]
67
+ generation.usage_details = trace_attributes[:usage_details] if trace_attributes[:usage_details]
68
+ generation.cost_details = trace_attributes[:cost_details] if trace_attributes[:cost_details]
69
+ generation.input = trace_attributes[:input] if trace_attributes[:input]
70
+ generation.output = trace_attributes[:output] if trace_attributes[:output]
71
+
72
+ generation.update_trace(input: trace_attributes[:input], output: trace_attributes[:output])
73
+ end
74
+
64
75
  # Recorded before the request is made so that a request which raises still
65
76
  # shows what was sent. Handlers whose input is not safe to record twice
66
77
  # (image generation sends base64 payloads that are replaced with media
@@ -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
@@ -4,6 +4,8 @@ require "uri"
4
4
 
5
5
  module NitroIntelligence
6
6
  class Reporter
7
+ class DatasetItemError < StandardError; end
8
+
7
9
  def initialize(observability_project_slug:)
8
10
  @observability_project_slug = observability_project_slug
9
11
  @project_client = fetch_project_client
@@ -20,7 +22,17 @@ module NitroIntelligence
20
22
  request["Authorization"] = "Basic #{@project_client.project.auth_token}"
21
23
  request.body = attributes.to_json
22
24
 
23
- http.request(request)
25
+ response = http.request(request)
26
+
27
+ # Every other request in this gem raises on an unsuccessful response. Without this a
28
+ # rejected write - bad credentials, a malformed item, a dataset that does not exist -
29
+ # is indistinguishable from a successful one, and a caller building a dataset ends up
30
+ # with a run against items that were never stored.
31
+ unless response.is_a?(Net::HTTPSuccess)
32
+ raise DatasetItemError, "#{response.code} creating dataset item: #{response.body}"
33
+ end
34
+
35
+ response
24
36
  end
25
37
 
26
38
  def score(trace_id:, name:, value:, id: "#{trace_id}-#{name}")
@@ -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.3.0".freeze
2
+ VERSION = "2.5.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.3.0
4
+ version: 2.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Igor Artemenko
@@ -57,14 +57,14 @@ dependencies:
57
57
  requirements:
58
58
  - - "~>"
59
59
  - !ruby/object:Gem::Version
60
- version: '0.58'
60
+ version: '0.79'
61
61
  type: :runtime
62
62
  prerelease: false
63
63
  version_requirements: !ruby/object:Gem::Requirement
64
64
  requirements:
65
65
  - - "~>"
66
66
  - !ruby/object:Gem::Version
67
- version: '0.58'
67
+ version: '0.79'
68
68
  description: The Ruby client for Nitro Intelligence
69
69
  email:
70
70
  - igor.artemenko@powerhrg.com
@@ -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