nitro_intelligence 4.0.0 → 4.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. checksums.yaml +4 -4
  2. data/.yardopts +9 -0
  3. data/Rakefile +21 -1
  4. data/docs/ASSISTANTS.md +594 -0
  5. data/docs/CHANGELOG.md +243 -0
  6. data/docs/README.md +140 -4
  7. data/lib/nitro_intelligence/assistant.rb +70 -1
  8. data/lib/nitro_intelligence/assistant_registry.rb +18 -0
  9. data/lib/nitro_intelligence/assistants.rb +146 -24
  10. data/lib/nitro_intelligence/client/base.rb +88 -8
  11. data/lib/nitro_intelligence/client/client.rb +20 -0
  12. data/lib/nitro_intelligence/client/factory.rb +1 -0
  13. data/lib/nitro_intelligence/client/handlers/audio_transcription_handler.rb +3 -1
  14. data/lib/nitro_intelligence/client/handlers/base_handler.rb +17 -2
  15. data/lib/nitro_intelligence/client/handlers/chat_handler.rb +3 -1
  16. data/lib/nitro_intelligence/client/handlers/decision_handler.rb +75 -0
  17. data/lib/nitro_intelligence/client/handlers/image_handler.rb +3 -1
  18. data/lib/nitro_intelligence/client/handlers/observed/audio_transcription_handler.rb +30 -12
  19. data/lib/nitro_intelligence/client/handlers/observed/chat_handler.rb +16 -3
  20. data/lib/nitro_intelligence/client/handlers/observed/decision_handler.rb +72 -0
  21. data/lib/nitro_intelligence/client/handlers/observed/image_handler.rb +20 -5
  22. data/lib/nitro_intelligence/client/handlers/observed/text_to_speech_handler.rb +27 -9
  23. data/lib/nitro_intelligence/client/handlers/text_to_speech_handler.rb +3 -1
  24. data/lib/nitro_intelligence/client/observed.rb +8 -0
  25. data/lib/nitro_intelligence/client/observers/langfuse_observer.rb +35 -7
  26. data/lib/nitro_intelligence/configuration.rb +46 -0
  27. data/lib/nitro_intelligence/decision.rb +105 -0
  28. data/lib/nitro_intelligence/langfuse_extension.rb +1 -0
  29. data/lib/nitro_intelligence/langfuse_tracer_provider.rb +7 -6
  30. data/lib/nitro_intelligence/media/audio.rb +6 -0
  31. data/lib/nitro_intelligence/media/image.rb +14 -1
  32. data/lib/nitro_intelligence/media/image_generation.rb +14 -2
  33. data/lib/nitro_intelligence/media/media.rb +4 -0
  34. data/lib/nitro_intelligence/models/model.rb +7 -0
  35. data/lib/nitro_intelligence/models/model_catalog.rb +3 -0
  36. data/lib/nitro_intelligence/models/model_factory.rb +2 -0
  37. data/lib/nitro_intelligence/null_cache.rb +2 -0
  38. data/lib/nitro_intelligence/observability/project.rb +41 -2
  39. data/lib/nitro_intelligence/observability/project_client.rb +10 -1
  40. data/lib/nitro_intelligence/observability/project_client_registry.rb +18 -0
  41. data/lib/nitro_intelligence/observability/prompt.rb +4 -3
  42. data/lib/nitro_intelligence/observability/prompt_resolver.rb +2 -0
  43. data/lib/nitro_intelligence/observability/prompt_store.rb +9 -0
  44. data/lib/nitro_intelligence/observability/upload_handler.rb +3 -1
  45. data/lib/nitro_intelligence/pending_review.rb +36 -1
  46. data/lib/nitro_intelligence/railtie.rb +1 -0
  47. data/lib/nitro_intelligence/reporter.rb +29 -0
  48. data/lib/nitro_intelligence/review_decision.rb +2 -0
  49. data/lib/nitro_intelligence/run_result.rb +31 -0
  50. data/lib/nitro_intelligence/tool_call_review_interrupt.rb +2 -0
  51. data/lib/nitro_intelligence/tool_call_review_validator.rb +2 -0
  52. data/lib/nitro_intelligence/trace.rb +8 -0
  53. data/lib/nitro_intelligence/version.rb +2 -1
  54. data/lib/nitro_intelligence.rb +37 -0
  55. metadata +7 -1
@@ -7,26 +7,43 @@ module NitroIntelligence
7
7
  # Credentials are supplied by the host. Where they come from is the host's business -- an
8
8
  # environment variable its deployment mounts, a secrets store, a literal in configuration --
9
9
  # so nothing here encodes one deployment's wiring.
10
+ #
11
+ # @example
12
+ # assistant = NitroIntelligence.assistants["candidate-concierge"]
13
+ # result = assistant.run(thread_id:, messages:)
10
14
  class Assistant
15
+ # Raised when an assistant's entry lacks its `api_key` or `assistant_id`, naming each one
16
+ # missing.
11
17
  class ConfigurationError < StandardError; end
12
18
 
19
+ # @api private
13
20
  DEFAULT_USER_ID = Assistants::DEFAULT_USER_ID
14
21
 
22
+ # @api private
15
23
  # `base_url` is not among them: the client defaults it, so it cannot be missing.
16
24
  REQUIRED = %w[api_key assistant_id].freeze
17
25
 
18
26
  # The key an assistant is filed and looked up under. Not its name: an entry usually carries
19
27
  # a `name` of its own, a human-readable label for the assistant's record, and the two are
20
28
  # different things.
29
+ #
30
+ # @return [String]
21
31
  attr_reader :key
22
32
 
23
- attr_reader :assistant_id, :base_url, :user_id
33
+ # @return [String] the assistant's id in Assistants
34
+ attr_reader :assistant_id
35
+ # @return [String] the Assistants deployment that serves it
36
+ attr_reader :base_url
37
+ # @return [String] the user its threads are created for
38
+ attr_reader :user_id
24
39
 
25
40
  # The key is positional so that splatting an entry cannot overwrite it, whatever the entry
26
41
  # happens to carry.
27
42
  #
28
43
  # Extra keys are accepted and ignored for the same reason: an entry carries fields the
29
44
  # application has no use for, such as the graph or the observability project it reports to.
45
+ #
46
+ # @api private
30
47
  def initialize(key, base_url: nil, api_key: nil, assistant_id: nil, user_id: nil, **_kwargs)
31
48
  @key = key.to_s
32
49
  @base_url = base_url.presence || Assistants::DEFAULT_BASE_URL
@@ -37,32 +54,84 @@ module NitroIntelligence
37
54
  validate!
38
55
  end
39
56
 
57
+ # The client the assistant's calls go through, for anything this class does not expose.
58
+ #
59
+ # @return [Assistants]
40
60
  def client
41
61
  @client ||= Assistants.new(base_url: @base_url, api_key: @api_key, user_id: @user_id)
42
62
  end
43
63
 
44
64
  # The calls that identify an assistant take it from here rather than from the caller.
45
65
  # Everything else is thread-scoped and delegates untouched.
66
+ #
67
+ # {Assistants#run} for this assistant.
68
+ #
69
+ # @param thread_id [String]
70
+ # @param messages [Array<Hash>]
71
+ # @param kwargs [Hash] the rest of {Assistants#run}'s arguments, except `assistant_id`
72
+ # @return [RunResult]
73
+ # @raise [ArgumentError] when given an `assistant_id`
74
+ # @raise (see Assistants#run)
46
75
  def run(thread_id:, messages:, **kwargs)
47
76
  reject_assistant_id!(kwargs)
48
77
  client.run(thread_id:, assistant_id:, messages:, **kwargs)
49
78
  end
50
79
 
80
+ # {Assistants#await_run} for this assistant.
81
+ #
82
+ # @deprecated Use {#run}`(...).reply`. Removed in 5.0.
83
+ # @param (see #run)
84
+ # @return [String, nil]
85
+ # @raise (see #run)
51
86
  def await_run(thread_id:, messages:, **kwargs)
52
87
  reject_assistant_id!(kwargs)
53
88
  client.await_run(thread_id:, assistant_id:, messages:, **kwargs)
54
89
  end
55
90
 
91
+ # {Assistants#review} for this assistant.
92
+ #
93
+ # @param thread_id [String]
94
+ # @param kwargs [Hash] the rest of {Assistants#review}'s arguments, except `assistant_id`
95
+ # @return [RunResult]
96
+ # @raise [ArgumentError] when given an `assistant_id`
97
+ # @raise (see Assistants#review)
56
98
  def review(thread_id:, **kwargs)
57
99
  reject_assistant_id!(kwargs)
58
100
  client.review(thread_id:, assistant_id:, **kwargs)
59
101
  end
60
102
 
103
+ # {Assistants#review_tool_calls} for this assistant.
104
+ #
105
+ # @deprecated Use {#review}`(tool_calls:, reviewer_id:, interrupt_id:)`. Removed in 5.0.
106
+ # @param thread_id [String]
107
+ # @param tool_calls [Hash, Array<Hash>]
108
+ # @param kwargs [Hash] the rest of {Assistants#review_tool_calls}'s arguments, except
109
+ # `assistant_id`
110
+ # @return [nil]
111
+ # @raise [ArgumentError] when given an `assistant_id`
112
+ # @raise (see Assistants#review_tool_calls)
61
113
  def review_tool_calls(thread_id:, tool_calls:, **kwargs)
62
114
  reject_assistant_id!(kwargs)
63
115
  client.review_tool_calls(thread_id:, assistant_id:, tool_calls:, **kwargs)
64
116
  end
65
117
 
118
+ # @!method thread_state(thread_id:)
119
+ # {Assistants#thread_state}.
120
+ # @return (see Assistants#thread_state)
121
+ # @!method thread_messages(thread_id:)
122
+ # {Assistants#thread_messages}.
123
+ # @return (see Assistants#thread_messages)
124
+ # @!method tool_calls_pending_review(thread_id:)
125
+ # {Assistants#tool_calls_pending_review}.
126
+ # @deprecated (see Assistants#tool_calls_pending_review)
127
+ # @return (see Assistants#tool_calls_pending_review)
128
+ # @!method tool_calls_under_review(thread_id:)
129
+ # {Assistants#tool_calls_under_review}.
130
+ # @deprecated (see Assistants#tool_calls_under_review)
131
+ # @return (see Assistants#tool_calls_under_review)
132
+ # @!method under_review(thread_id:)
133
+ # {Assistants#under_review}.
134
+ # @return (see Assistants#under_review)
66
135
  delegate :thread_state, :thread_messages, :tool_calls_pending_review, :tool_calls_under_review,
67
136
  :under_review, to: :client
68
137
 
@@ -17,29 +17,47 @@ module NitroIntelligence
17
17
  # },
18
18
  # }
19
19
  class AssistantRegistry
20
+ # Raised looking up a key `definitions` does not configure, listing the ones it does.
20
21
  class UnknownAssistantError < StandardError; end
21
22
 
23
+ # @api private
22
24
  DEFINITIONS_KEY = "definitions".freeze
25
+ # @api private
23
26
  SHARED_KEYS = %w[base_url user_id].freeze
24
27
 
28
+ # @api private
25
29
  def initialize(config = {})
26
30
  @config = config.to_h.deep_stringify_keys
27
31
  @assistants = {}
28
32
  end
29
33
 
34
+ # The assistant configured under `key`.
35
+ #
36
+ # @param key [String, Symbol]
37
+ # @return [Assistant]
38
+ # @raise [UnknownAssistantError] when `definitions` has no entry for `key`
39
+ # @raise [Assistant::ConfigurationError] when the entry lacks its `api_key` or `assistant_id`
30
40
  def [](key)
31
41
  fetch(key)
32
42
  end
33
43
 
44
+ # @param (see #[])
45
+ # @return (see #[])
46
+ # @raise (see #[])
34
47
  def fetch(key)
35
48
  key = key.to_s
36
49
  @assistants[key] ||= build(key)
37
50
  end
38
51
 
52
+ # Whether `definitions` has an entry for `key`.
53
+ #
54
+ # @param key [String, Symbol]
55
+ # @return [Boolean]
39
56
  def key?(key)
40
57
  definitions.key?(key.to_s)
41
58
  end
42
59
 
60
+ # @return [Array<String>] the keys `definitions` configures
43
61
  def keys
44
62
  definitions.keys
45
63
  end
@@ -6,34 +6,59 @@ require "nitro_intelligence/pending_review"
6
6
  require "nitro_intelligence/run_result"
7
7
 
8
8
  module NitroIntelligence
9
+ # The client for Nitro Intelligence Assistants: runs assistants on threads, and resumes runs
10
+ # paused for review. Most hosts reach it through {NitroIntelligence.assistants}, or through an
11
+ # {Assistant}, which supplies `assistant_id` itself. See `docs/ASSISTANTS.md`.
9
12
  class Assistants
13
+ # Raised by {#initialize} without an `api_key` or `user_id`.
10
14
  class ConfigurationError < StandardError; end
15
+ # Raised by {#run} when Assistants refuses to create or seed the thread, or the assistant has no
16
+ # graph. The message is the response body.
11
17
  class ThreadInitializationError < StandardError; end
18
+ # Raised by {#run} when Assistants rejects the run, and when the run itself fails. The message
19
+ # carries the failure Assistants reported.
12
20
  class RunError < StandardError; end
21
+ # Raised when resuming a thread fails: the thread is not paused on what the review answers, a
22
+ # decision is one the pause does not allow, or Assistants refuses the resume.
13
23
  class ThreadResumptionError < StandardError; end
24
+ # Raised by {#thread_state}, {#thread_messages} and {#under_review} when the thread's state cannot
25
+ # be read. The message is the response body.
14
26
  class ThreadStateError < StandardError; end
15
27
  # The review being answered is no longer the one the thread is paused on. A subclass, so that
16
28
  # callers rescuing ThreadResumptionError still catch it.
17
29
  class ReviewSupersededError < ThreadResumptionError; end
18
30
 
31
+ # @api private
19
32
  # What Assistants answers, with a 400, to a resume of a thread that is no longer paused.
20
33
  NOT_INTERRUPTED_REFUSAL = "thread is not in interrupted state".freeze
21
34
 
35
+ # @api private
22
36
  # Assistants answers with a conflict when `ifExists: "raise"` is sent for a thread that already exists.
23
37
  THREAD_CONFLICT_CODE = 409
24
38
 
39
+ # @api private
25
40
  # Where a `/runs/wait` response says which run it waited on.
26
41
  RUN_LOCATION = %r{/runs/([^/]+)\z}
27
42
 
43
+ # @api private
28
44
  # Every assistant this gem is built for is served by the same deployment, so a client told
29
45
  # nothing about where to reach one gets that deployment. A host talking to a different one
30
46
  # -- a review environment, a local server -- still says so.
31
47
  DEFAULT_BASE_URL = "https://assistants.powerhome.ai".freeze
32
48
 
49
+ # The user threads are created for when a client is given none.
33
50
  DEFAULT_USER_ID = "default-user".freeze
34
51
 
35
- attr_reader :base_url, :user_id
52
+ # @return [String] the Assistants deployment this client calls
53
+ attr_reader :base_url
54
+ # @return [String] the user threads are created for
55
+ attr_reader :user_id
36
56
 
57
+ # @param api_key [String] the key Assistants authenticates the client with
58
+ # @param base_url [String, nil] the Assistants deployment to call. Defaults to the shared one.
59
+ # @param user_id [String] the user threads are created for
60
+ # @raise [ConfigurationError] when `api_key` or `user_id` is blank. Omitting `api_key` raises
61
+ # Ruby's `ArgumentError` for a missing keyword instead.
37
62
  def initialize(api_key:, base_url: nil, user_id: DEFAULT_USER_ID)
38
63
  raise ConfigurationError, "api_key is required" if api_key.blank?
39
64
  raise ConfigurationError, "user_id is required" if user_id.blank?
@@ -47,6 +72,15 @@ module NitroIntelligence
47
72
  # Runs the last of `messages` on the thread, seeding a new thread with the ones before it, and
48
73
  # waits for the run to end or pause. Returns a RunResult: its `reply` is nil whenever the run
49
74
  # paused, and its `review` says what the run is waiting on.
75
+ #
76
+ # @param thread_id [String] the thread, created on its first run
77
+ # @param assistant_id [String] the assistant to run
78
+ # @param messages [Array<Hash>] the conversation, oldest first: the last is run, and the ones
79
+ # before it seed a new thread
80
+ # @param context [Hash] the run's context, such as the variables its prompt needs
81
+ # @return [RunResult]
82
+ # @raise [RunError] when `messages` is empty, Assistants rejects the run, or the run fails
83
+ # @raise [ThreadInitializationError] when a new thread cannot be created or seeded
50
84
  def run(thread_id:, assistant_id:, messages:, context: {})
51
85
  raise RunError, "messages cannot be empty" if messages.blank?
52
86
 
@@ -59,7 +93,11 @@ module NitroIntelligence
59
93
 
60
94
  # The run's reply, or nil when it paused for review or ended without one.
61
95
  #
62
- # Deprecated: #run says why there is no reply, and how the platform delivered the one there is.
96
+ # @deprecated Use {#run}`(...).reply`, which also says why there is no reply, and how the
97
+ # platform delivered the one there is. Removed in 5.0.
98
+ # @param (see #run)
99
+ # @return [String, nil]
100
+ # @raise (see #run)
63
101
  def await_run(thread_id:, assistant_id:, messages:, context: {})
64
102
  NitroIntelligence.deprecator.warn(
65
103
  "#await_run is deprecated and is removed in 5.0; use #run(...).reply, and read #run's " \
@@ -70,12 +108,20 @@ module NitroIntelligence
70
108
 
71
109
  # The thread's state as Assistants reports it, unformatted. Callers that only want the
72
110
  # conversation should reach for #thread_messages instead.
111
+ #
112
+ # @param thread_id [String]
113
+ # @return [Hash] the `GET /threads/{id}/state` response
114
+ # @raise [ThreadStateError] when the state cannot be read
73
115
  def thread_state(thread_id:)
74
116
  get_thread_state(thread_id:, error: ThreadStateError)
75
117
  end
76
118
 
77
119
  # The thread's messages as Assistants reports them, unformatted, oldest first. Each message
78
120
  # carries its own `type` ("human", "ai", "tool", ...), which callers map to their own roles.
121
+ #
122
+ # @param thread_id [String]
123
+ # @return [Array<Hash>]
124
+ # @raise [ThreadStateError] when the state cannot be read
79
125
  def thread_messages(thread_id:)
80
126
  messages_in(thread_state(thread_id:))
81
127
  end
@@ -83,9 +129,14 @@ module NitroIntelligence
83
129
  # Every tool call in the thread that has no result yet, whether or not it is held for review,
84
130
  # with the id of the message before it.
85
131
  #
86
- # Deprecated: a reviewer decides only the calls the interrupt holds, which #under_review reports.
87
132
  # nitro-web's VirtualConfirmationAgent::Client still reads this, since the VCA's interrupt is not
88
133
  # in the platform's shape; it goes when the VCA moves onto the platform.
134
+ #
135
+ # @deprecated A reviewer decides only the calls the interrupt holds, which {#under_review}
136
+ # reports. Removed in 5.0.
137
+ # @param thread_id [String]
138
+ # @return [Array<Hash>] each with `previous_message_id`, `id`, `name` and `args`
139
+ # @raise [ThreadResumptionError] when the state cannot be read
89
140
  def tool_calls_pending_review(thread_id:)
90
141
  NitroIntelligence.deprecator.warn(
91
142
  "#tool_calls_pending_review is deprecated and is removed in 5.0; use " \
@@ -120,7 +171,12 @@ module NitroIntelligence
120
171
  # Raises ThreadResumptionError when the state cannot be read, as it always has; #under_review is
121
172
  # the plain read.
122
173
  #
123
- # Deprecated in favour of #under_review, which reports a pause on the reply as well.
174
+ # @deprecated Use {#under_review}`(thread_id:)&.tool_calls.to_a`, which reports a pause on the
175
+ # reply as well. Removed in 5.0.
176
+ # @param thread_id [String]
177
+ # @return [Array<Hash>] each with `previous_message_id`, `id`, `name`, `args` and
178
+ # `allowed_decisions`
179
+ # @raise [ThreadResumptionError] when the state cannot be read
124
180
  def tool_calls_under_review(thread_id:)
125
181
  NitroIntelligence.deprecator.warn(
126
182
  "#tool_calls_under_review is deprecated and is removed in 5.0; use " \
@@ -131,6 +187,10 @@ module NitroIntelligence
131
187
 
132
188
  # What the thread is paused on -- its tool calls or its reply -- or nil when it is not waiting
133
189
  # on a review.
190
+ #
191
+ # @param thread_id [String]
192
+ # @return [PendingReview, nil]
193
+ # @raise [ThreadStateError] when the state cannot be read
134
194
  def under_review(thread_id:)
135
195
  PendingReview.from_thread_state(get_thread_state(thread_id:, error: ThreadStateError))
136
196
  end
@@ -153,6 +213,25 @@ module NitroIntelligence
153
213
  # here, so it never decides a pause that appeared since; reporting such a resume as
154
214
  # ReviewSupersededError is best-effort (see PendingReview#unapplied_resume?), as is reporting one
155
215
  # Assistants refuses because another review resumed the thread first.
216
+ #
217
+ # @param thread_id [String]
218
+ # @param assistant_id [String] the assistant the resumed run runs
219
+ # @param reviewer_id [String] who decided, as the host identifies them
220
+ # @param reply [Hash, nil] the review of a held reply
221
+ # @param tool_calls [Array<Hash>, Hash, nil] the reviews of held tool calls: a list of reviews
222
+ # that each carry their tool call's `id`, or a hash of reviews keyed by tool call id
223
+ # @param interrupt_id [String, nil] the pause the reviewer was shown; required for a reply
224
+ # @param reviewed_at [Time, String, nil] when they decided. Defaults to now.
225
+ # @param context [Hash] the resumed run's context, as for {#run}
226
+ # @return [RunResult] the resumed run
227
+ # @raise [ArgumentError] without exactly one of `reply` and `tool_calls`, without a `reviewer_id`,
228
+ # or reviewing a reply without an `interrupt_id`
229
+ # @raise [ReviewSupersededError] when the thread is no longer paused on the review answered
230
+ # @raise [ThreadResumptionError] when a decision is not one the pause allows, the thread is paused
231
+ # on the other kind of review, or the resume fails
232
+ # @example
233
+ # assistants.review(thread_id:, assistant_id:, reviewer_id: "jane@example.com",
234
+ # interrupt_id: pending.interrupt_id, reply: { "action" => "approve" })
156
235
  def review(thread_id:, assistant_id:, reviewer_id:, reply: nil, tool_calls: nil, interrupt_id: nil, # rubocop:disable Metrics/ParameterLists
157
236
  reviewed_at: nil, context: {})
158
237
  kind, reviews = review_subject!(reply:, tool_calls:)
@@ -188,7 +267,18 @@ module NitroIntelligence
188
267
  # a resume Assistants refuses because the thread is no longer paused is raised as it always was, a
189
268
  # ThreadResumptionError carrying the response body, which nitro-web parses. Returns nil.
190
269
  #
191
- # Deprecated in favour of #review, which requires the reviewer and returns the resumed run.
270
+ # @deprecated Use {#review}`(tool_calls:, reviewer_id:, interrupt_id:)`, which requires the
271
+ # reviewer and returns the resumed run. Removed in 5.0.
272
+ # @param thread_id [String]
273
+ # @param assistant_id [String]
274
+ # @param tool_calls [Hash, Array<Hash>] the reviews
275
+ # @param context [Hash]
276
+ # @param reviewer_id [String, nil]
277
+ # @param reviewed_at [Time, String, nil]
278
+ # @return [nil]
279
+ # @raise [ReviewSupersededError] when the resumed run shows the decisions were not applied
280
+ # @raise [ThreadResumptionError] when the thread is not paused on tool calls, a decision is not one
281
+ # the pause allows, or the resume fails
192
282
  def review_tool_calls(thread_id:, assistant_id:, tool_calls:, context: {}, reviewer_id: nil, reviewed_at: nil)
193
283
  NitroIntelligence.deprecator.warn(
194
284
  "#review_tool_calls is deprecated and is removed in 5.0; use " \
@@ -214,8 +304,59 @@ module NitroIntelligence
214
304
  # `get_thread_state` and `resume_run` below. Renaming any of the four breaks that override --
215
305
  # loudly, in nitro-web's suite at bump time rather than in anything here. The subclass goes when
216
306
  # the VCA moves onto this platform, and this note with it.
307
+ #
308
+ # The four are private to callers and public to subclasses, so they are documented as protected.
217
309
  private
218
310
 
311
+ # Reads a thread. For subclasses.
312
+ #
313
+ # @!visibility protected
314
+ # @param thread_id [String]
315
+ # @return [Hash] the `GET /threads/{id}` response
316
+ # @raise [ThreadResumptionError] when the thread cannot be read. The message is the response body.
317
+ def get_thread(thread_id:)
318
+ parse!(get(path: "/threads/#{thread_id}"), ThreadResumptionError)
319
+ end
320
+
321
+ # Whether a thread, as {#get_thread} reads it, is paused. For subclasses.
322
+ #
323
+ # @!visibility protected
324
+ # @param thread [Hash]
325
+ # @return [Boolean]
326
+ def interrupted?(thread)
327
+ thread["status"] == "interrupted"
328
+ end
329
+
330
+ # Reads a thread's state. For subclasses.
331
+ #
332
+ # The review flows have always raised ThreadResumptionError when a state read fails, and consumers
333
+ # rescue it as such. A plain read resumes nothing, so #thread_state asks for ThreadStateError.
334
+ #
335
+ # @!visibility protected
336
+ # @param thread_id [String]
337
+ # @param error [Class<StandardError>] what to raise when the state cannot be read
338
+ # @return [Hash] the `GET /threads/{id}/state` response
339
+ # @raise [StandardError] `error`, with the response body as its message
340
+ def get_thread_state(thread_id:, error: ThreadResumptionError)
341
+ parse!(get(path: "/threads/#{thread_id}/state"), error)
342
+ end
343
+
344
+ # Resumes a paused thread with `resume` as its `command.resume`, sent as given, and waits for the
345
+ # run. For subclasses that build their own resume.
346
+ #
347
+ # @!visibility protected
348
+ # @param thread_id [String]
349
+ # @param assistant_id [String]
350
+ # @param resume [Hash, Array] the `command.resume`
351
+ # @param context [Hash]
352
+ # @return [Hash] the `/runs/wait` response
353
+ # @raise [ThreadResumptionError] when Assistants refuses the resume, with the response body as its
354
+ # message, or the resumed run fails
355
+ def resume_run(thread_id:, assistant_id:, resume:, context:)
356
+ wait_for_run(thread_id:, body: { assistant_id:, command: { resume: }, context: }, error: ThreadResumptionError)
357
+ .first
358
+ end
359
+
219
360
  # Assistants accepts `initial_state` on thread creation but never applies it, so a brand new thread is
220
361
  # seeded through the thread state endpoint instead. A thread that already exists is left untouched:
221
362
  # its state was seeded when it was created and has been built up by every run since.
@@ -281,16 +422,6 @@ module NitroIntelligence
281
422
  response.code.to_i == THREAD_CONFLICT_CODE
282
423
  end
283
424
 
284
- # The review flows have always raised ThreadResumptionError when a state read fails, and consumers
285
- # rescue it as such. A plain read resumes nothing, so #thread_state asks for ThreadStateError.
286
- def get_thread_state(thread_id:, error: ThreadResumptionError)
287
- parse!(get(path: "/threads/#{thread_id}/state"), error)
288
- end
289
-
290
- def get_thread(thread_id:)
291
- parse!(get(path: "/threads/#{thread_id}"), ThreadResumptionError)
292
- end
293
-
294
425
  def trigger_run(thread_id:, assistant_id:, last_message:, context: {})
295
426
  run, = wait_for_run(thread_id:, body: { assistant_id:, context:, input: { messages: [last_message] } },
296
427
  error: RunError)
@@ -316,11 +447,6 @@ module NitroIntelligence
316
447
  raise error, detail
317
448
  end
318
449
 
319
- def resume_run(thread_id:, assistant_id:, resume:, context:)
320
- wait_for_run(thread_id:, body: { assistant_id:, command: { resume: }, context: }, error: ThreadResumptionError)
321
- .first
322
- end
323
-
324
450
  def review_subject!(reply:, tool_calls:)
325
451
  subjects = { reply:, tool_calls: }.compact
326
452
  return subjects.first if subjects.one?
@@ -379,10 +505,6 @@ module NitroIntelligence
379
505
  raise ReviewSupersededError, message
380
506
  end
381
507
 
382
- def interrupted?(thread)
383
- thread["status"] == "interrupted"
384
- end
385
-
386
508
  def messages_in(thread_state)
387
509
  Array(thread_state.dig("values", "messages"))
388
510
  end
@@ -1,35 +1,111 @@
1
1
  require "nitro_intelligence/client/handlers/audio_transcription_handler"
2
2
  require "nitro_intelligence/client/handlers/chat_handler"
3
+ require "nitro_intelligence/client/handlers/decision_handler"
3
4
  require "nitro_intelligence/client/handlers/image_handler"
4
5
  require "nitro_intelligence/client/handlers/text_to_speech_handler"
5
6
 
6
7
  module NitroIntelligence
7
8
  module Client
9
+ # A client {Client.new} builds. Every method takes `parameters`, the request's openai-ruby
10
+ # parameters plus this gem's own (`prompt_name`, `prompt_variables`, `trace_seed`, `metadata`,
11
+ # `user_id` and the rest the README describes), and defaults the model to the configured
12
+ # default for its modality.
13
+ #
14
+ # Each request works on a copy of the caller's `parameters`, which the handlers fill in with
15
+ # defaults and gateway headers, so a hash passed to several requests carries nothing from one
16
+ # request into the next.
8
17
  class Base
18
+ # @api private
9
19
  attr_reader :client
10
20
 
21
+ # @api private
11
22
  def initialize(client:)
12
23
  @client = client
13
24
  end
14
25
 
26
+ # Creates a chat completion.
27
+ #
28
+ # @param message [String] the user's message, used when `parameters` carries no `messages`
29
+ # @param parameters [Hash] openai-ruby's chat completion parameters, and this gem's own
30
+ # @return [OpenAI::Models::Chat::ChatCompletion]
31
+ # @raise [ArgumentError] when the model is not one `model_config` lists
32
+ # @raise [Handlers::Observed::ChatHandler::ObservedChatPromptError] on an observed client, when
33
+ # the request carries no user message for the model to answer
34
+ # @example
35
+ # client.chat(message: "Why is the sky blue?").choices.first&.message&.content
15
36
  def chat(message: "", parameters: {})
16
- chat_handler.create(message:, parameters:)
37
+ chat_handler.create(message:, parameters: parameters.dup)
17
38
  end
18
39
 
19
- # Input images should be byte strings. Returns NitroIntelligence::ImageGeneration
40
+ # Asks a decision model questions about some content over the gateway's System One route,
41
+ # `POST /v1/systemone`.
42
+ #
43
+ # @param state [String, Hash, Array] the content the questions are about
44
+ # @param questions [Hash] each question's name mapped to its `type` (`noul`, `choice` or
45
+ # `score`), `instructions` and, for `choice` and `score`, `criteria`: the options keyed by
46
+ # name, or the levels in order
47
+ # @param parameters [Hash] `model`, `assets` (images `state` and the questions refer to),
48
+ # `metadata` and `request_options`, and on an observed client `trace_name`, `trace_seed`,
49
+ # `user_id`, `session_id` and `tags`. It takes no managed prompt: `prompt_name` and
50
+ # `prompt_variables` are ignored, so the instructions belong in `questions`.
51
+ # @return [Decision]
52
+ # @raise [ArgumentError] when the model is not one `model_config` lists with the type
53
+ # `decision`
54
+ # @example
55
+ # client.decide(
56
+ # state: "I was charged twice.",
57
+ # questions: { billing: { type: "noul", instructions: "Is this message about billing?" } }
58
+ # ).answers[:billing].noul
59
+ def decide(state:, questions:, parameters: {})
60
+ decision_handler.create(state:, questions:, parameters: parameters.dup)
61
+ end
62
+
63
+ # Generates an image, or edits `target_image`.
64
+ #
65
+ # @param message [String] what to generate, or how to edit the target
66
+ # @param target_image [String, nil] the image to edit, as a byte string
67
+ # @param reference_images [Array<String>] further images to draw on, as byte strings
68
+ # @param parameters [Hash] `model`, `aspect_ratio` and `resolution`, among those `model_config`
69
+ # lists for the model, and this gem's own
70
+ # @return [ImageGeneration]
71
+ # @raise [ArgumentError] when the model, aspect ratio or resolution is not one `model_config`
72
+ # lists
73
+ # @example
74
+ # result = client.generate_image(message: "A bear installing a window")
75
+ # File.binwrite("bear.#{result.generated_image.file_extension}", result.generated_image.byte_string)
20
76
  def generate_image(message: "", target_image: nil, reference_images: [], parameters: {})
21
- image_handler.create(message:, target_image:, reference_images:, parameters:)
77
+ image_handler.create(message:, target_image:, reference_images:, parameters: parameters.dup)
22
78
  end
23
79
 
24
- # Audio file should be file object with extension in the filename
25
- # Use file_extension for now to prevent a library dependency
80
+ # Transcribes audio into text.
81
+ #
82
+ # @param message [String] a prompt to guide the transcription, such as its vocabulary or style
83
+ # @param audio_file [File] the audio, opened in binary mode, with a filename whose extension
84
+ # gives its format
85
+ # @param parameters [Hash] openai-ruby's transcription parameters, and this gem's own
86
+ # @return [OpenAI::Models::Audio::Transcription, OpenAI::Models::Audio::TranscriptionVerbose,
87
+ # OpenAI::Models::Audio::TranscriptionDiarized, StringIO] whichever openai-ruby returns for
88
+ # the `response_format`: a `Transcription` for `json`, the default, and the response body as a
89
+ # StringIO for `text`, `srt` and `vtt`. An observed client records the transcription's `text`,
90
+ # so it supports only the formats that return an object.
91
+ # @raise [ArgumentError] when the model is not one `model_config` lists
92
+ # @example
93
+ # client.transcribe_audio(audio_file: File.open("sample.m4a", "rb")).text
26
94
  def transcribe_audio(message: +"", audio_file: nil, parameters: {})
27
- audio_transcription_handler.create(message:, audio_file:, parameters:)
95
+ audio_transcription_handler.create(message:, audio_file:, parameters: parameters.dup)
28
96
  end
29
97
 
30
- # Returns StringIO
98
+ # Speaks text aloud.
99
+ #
100
+ # @param message [String] the text to speak
101
+ # @param parameters [Hash] openai-ruby's speech parameters, with `voice` and `response_format`
102
+ # among those `model_config` lists for the model, and this gem's own
103
+ # @return [StringIO] the audio
104
+ # @raise [ArgumentError] when the model, voice or format is not one `model_config` lists
105
+ # @example
106
+ # File.binwrite("hello.mp3", client.text_to_speech(message: "Hello").string)
31
107
  def text_to_speech(message: "", parameters: {})
32
- text_to_speech_handler.create(message:, parameters:)
108
+ text_to_speech_handler.create(message:, parameters: parameters.dup)
33
109
  end
34
110
 
35
111
  private
@@ -42,6 +118,10 @@ module NitroIntelligence
42
118
  @chat_handler ||= Handlers::ChatHandler.new(client: @client)
43
119
  end
44
120
 
121
+ def decision_handler
122
+ @decision_handler ||= Handlers::DecisionHandler.new(client: @client)
123
+ end
124
+
45
125
  def image_handler
46
126
  @image_handler ||= Handlers::ImageHandler.new(client: @client)
47
127
  end
@@ -1,11 +1,31 @@
1
1
  require "nitro_intelligence/client/factory"
2
2
 
3
3
  module NitroIntelligence
4
+ # Inference through the inference gateway: chat, image generation and editing, audio
5
+ # transcription and text-to-speech. See the README's "Basic Usage".
4
6
  module Client
7
+ # Builds a client, which records every request in Cerebro when given a project to report to.
8
+ #
9
+ # A project slug that `observability_projects` does not configure logs a warning and gives a
10
+ # client without observability rather than raising. Building a client makes no request to
11
+ # Cerebro, so a configured project with wrong credentials still gives an observed client, and the
12
+ # error surfaces on the first request that reaches Cerebro, such as a prompt lookup.
13
+ #
14
+ # @param observability_project_slug [String, nil] the Cerebro project to record requests in, and
15
+ # to fetch prompts from
16
+ # @param inference_max_retries [Integer, nil] overrides the configured `inference_max_retries`
17
+ # for this client
18
+ # @param inference_timeout [Numeric, nil] overrides the configured `inference_timeout` for this
19
+ # client, in seconds
20
+ # @return [Client::Base]
21
+ # @example
22
+ # client = NitroIntelligence::Client.new(observability_project_slug: "my-feature-project")
23
+ # client.chat(message: "Why is the sky blue?")
5
24
  def self.new(observability_project_slug: nil, inference_max_retries: nil, inference_timeout: nil)
6
25
  Factory.new(observability_project_slug:, inference_max_retries:, inference_timeout:).build
7
26
  end
8
27
 
28
+ # @api private
9
29
  def self.validate_model(model)
10
30
  raise ArgumentError, "Unsupported model: '#{model}'" unless NitroIntelligence.model_catalog.exists?(model)
11
31
  end
@@ -5,6 +5,7 @@ require "nitro_intelligence/observability/project"
5
5
 
6
6
  module NitroIntelligence
7
7
  module Client
8
+ # @api private
8
9
  class Factory
9
10
  def initialize(observability_project_slug:, inference_max_retries: nil, inference_timeout: nil)
10
11
  @observability_project_slug = observability_project_slug
@@ -4,6 +4,7 @@ require "nitro_intelligence/client/handlers/base_handler"
4
4
  module NitroIntelligence
5
5
  module Client
6
6
  module Handlers
7
+ # @api private
7
8
  class AudioTranscriptionHandler < BaseHandler
8
9
  ALLOWED_EXTRA_PARAMETERS = OpenAI::Models::Audio::TranscriptionCreateParams.fields.keys.uniq.freeze
9
10
 
@@ -12,9 +13,10 @@ module NitroIntelligence
12
13
  perform_request(audio_file:, message:, parameters:)
13
14
  end
14
15
 
15
- def perform_request(audio_file:, message: "", parameters: {}, correlation_trace_id: nil)
16
+ def perform_request(audio_file:, message: "", parameters: {}, correlation_trace_id: nil, mask_content: false)
16
17
  add_request_headers(parameters, MODALITY_HEADER => "audio", REQUESTED_MODEL_HEADER => parameters[:model])
17
18
  add_correlation_headers(parameters, trace_id: correlation_trace_id)
19
+ add_message_redaction_header(parameters, mask_content:)
18
20
  @client.audio.transcriptions.create(
19
21
  prompt: message,
20
22
  file: audio_file,