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.
- checksums.yaml +4 -4
- data/.yardopts +9 -0
- data/Rakefile +21 -1
- data/docs/ASSISTANTS.md +594 -0
- data/docs/CHANGELOG.md +243 -0
- data/docs/README.md +140 -4
- data/lib/nitro_intelligence/assistant.rb +70 -1
- data/lib/nitro_intelligence/assistant_registry.rb +18 -0
- data/lib/nitro_intelligence/assistants.rb +146 -24
- data/lib/nitro_intelligence/client/base.rb +88 -8
- data/lib/nitro_intelligence/client/client.rb +20 -0
- data/lib/nitro_intelligence/client/factory.rb +1 -0
- data/lib/nitro_intelligence/client/handlers/audio_transcription_handler.rb +3 -1
- data/lib/nitro_intelligence/client/handlers/base_handler.rb +17 -2
- data/lib/nitro_intelligence/client/handlers/chat_handler.rb +3 -1
- data/lib/nitro_intelligence/client/handlers/decision_handler.rb +75 -0
- data/lib/nitro_intelligence/client/handlers/image_handler.rb +3 -1
- data/lib/nitro_intelligence/client/handlers/observed/audio_transcription_handler.rb +30 -12
- data/lib/nitro_intelligence/client/handlers/observed/chat_handler.rb +16 -3
- data/lib/nitro_intelligence/client/handlers/observed/decision_handler.rb +72 -0
- data/lib/nitro_intelligence/client/handlers/observed/image_handler.rb +20 -5
- data/lib/nitro_intelligence/client/handlers/observed/text_to_speech_handler.rb +27 -9
- data/lib/nitro_intelligence/client/handlers/text_to_speech_handler.rb +3 -1
- data/lib/nitro_intelligence/client/observed.rb +8 -0
- data/lib/nitro_intelligence/client/observers/langfuse_observer.rb +35 -7
- data/lib/nitro_intelligence/configuration.rb +46 -0
- data/lib/nitro_intelligence/decision.rb +105 -0
- data/lib/nitro_intelligence/langfuse_extension.rb +1 -0
- data/lib/nitro_intelligence/langfuse_tracer_provider.rb +7 -6
- data/lib/nitro_intelligence/media/audio.rb +6 -0
- data/lib/nitro_intelligence/media/image.rb +14 -1
- data/lib/nitro_intelligence/media/image_generation.rb +14 -2
- data/lib/nitro_intelligence/media/media.rb +4 -0
- data/lib/nitro_intelligence/models/model.rb +7 -0
- data/lib/nitro_intelligence/models/model_catalog.rb +3 -0
- data/lib/nitro_intelligence/models/model_factory.rb +2 -0
- data/lib/nitro_intelligence/null_cache.rb +2 -0
- data/lib/nitro_intelligence/observability/project.rb +41 -2
- data/lib/nitro_intelligence/observability/project_client.rb +10 -1
- data/lib/nitro_intelligence/observability/project_client_registry.rb +18 -0
- data/lib/nitro_intelligence/observability/prompt.rb +4 -3
- data/lib/nitro_intelligence/observability/prompt_resolver.rb +2 -0
- data/lib/nitro_intelligence/observability/prompt_store.rb +9 -0
- data/lib/nitro_intelligence/observability/upload_handler.rb +3 -1
- data/lib/nitro_intelligence/pending_review.rb +36 -1
- data/lib/nitro_intelligence/railtie.rb +1 -0
- data/lib/nitro_intelligence/reporter.rb +29 -0
- data/lib/nitro_intelligence/review_decision.rb +2 -0
- data/lib/nitro_intelligence/run_result.rb +31 -0
- data/lib/nitro_intelligence/tool_call_review_interrupt.rb +2 -0
- data/lib/nitro_intelligence/tool_call_review_validator.rb +2 -0
- data/lib/nitro_intelligence/trace.rb +8 -0
- data/lib/nitro_intelligence/version.rb +2 -1
- data/lib/nitro_intelligence.rb +37 -0
- 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
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
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
|
-
#
|
|
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
|
-
#
|
|
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
|
-
#
|
|
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
|
-
#
|
|
25
|
-
#
|
|
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
|
-
#
|
|
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,
|