patient_llm 0.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 +7 -0
- data/CHANGELOG.md +14 -0
- data/MIT-LICENSE +20 -0
- data/README.md +323 -0
- data/VERSION +1 -0
- data/lib/patient_llm/callback.rb +260 -0
- data/lib/patient_llm/configuration.rb +50 -0
- data/lib/patient_llm/halt_error.rb +23 -0
- data/lib/patient_llm/max_tool_iterations_error.rb +11 -0
- data/lib/patient_llm.rb +151 -0
- data/patient_llm.gemspec +45 -0
- metadata +109 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 7873a0b7daf57787415d4484495a17f130cbf3a14137613799651aa0007f2d63
|
|
4
|
+
data.tar.gz: 728755787c1a52c805ebd70eaf163fae702d55b9525325b101b9dae571ad1652
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: c101043564f214413f1a50b3536d56eff6f611e227013cc26507fd7b8465f1554bb6922de98f736f0712bda809afa2654bf1c366740c8b5ca3e673074e5738d7
|
|
7
|
+
data.tar.gz: 80d9a476d5fbe99a96946558a1d847c7faad08250584f9d0b5b452d6faeb906066d64b9c5e822139aed84f22d41c0059e035d59cefc9dc75979b8c4bffb7fd20
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
All notable changes to this project will be documented in this file.
|
|
3
|
+
|
|
4
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
5
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
|
+
|
|
7
|
+
## 0.1.0
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- Initial implementation: async OpenAI Chat Completions calls via `patient_http`.
|
|
12
|
+
- Tool calling with automatic execution loop and `halt` short-circuit via `PromptBuilder.tool_registry`.
|
|
13
|
+
- Provider registry, JSON-schema structured output, reasoning effort, custom headers/params.
|
|
14
|
+
- Session state serialization/restoration via `PromptBuilder::Session`.
|
data/MIT-LICENSE
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
Copyright 2026 Brian Durand
|
|
2
|
+
|
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
|
4
|
+
a copy of this software and associated documentation files (the
|
|
5
|
+
"Software"), to deal in the Software without restriction, including
|
|
6
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
|
7
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
|
8
|
+
permit persons to whom the Software is furnished to do so, subject to
|
|
9
|
+
the following conditions:
|
|
10
|
+
|
|
11
|
+
The above copyright notice and this permission notice shall be
|
|
12
|
+
included in all copies or substantial portions of the Software.
|
|
13
|
+
|
|
14
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
|
15
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
|
16
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
|
17
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
|
18
|
+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
|
19
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
|
20
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
# PatientLLM
|
|
2
|
+
|
|
3
|
+
[](https://github.com/bdurand/patient_llm/actions/workflows/continuous_integration.yml)
|
|
4
|
+
[](https://github.com/testdouble/standard)
|
|
5
|
+
[](https://badge.fury.io/rb/patient_llm)
|
|
6
|
+
|
|
7
|
+
Integrate LLM APIs with your Ruby backend applications without blocking threads. This gem uses asynchronous HTTP requests to call LLM providers and handles the response via callbacks. It supports multiple API formats natively via [PromptBuilder](https://github.com/bdurand/prompt_builder) serializers:
|
|
8
|
+
|
|
9
|
+
- **OpenAI Chat Completions** (`:chat_completion`) -- for OpenAI and compatible providers
|
|
10
|
+
- **OpenAI Responses** (`:open_responses`) -- for the newer OpenAI Responses API
|
|
11
|
+
- **Anthropic Messages** (`:messages`) -- for the Anthropic Claude API
|
|
12
|
+
- **Bedrock Converse** (`:converse`) -- for AWS Bedrock Converse API
|
|
13
|
+
- **Gemini** (`:gemini`) -- for the Google Gemini API
|
|
14
|
+
|
|
15
|
+
LLM API calls can take a long time to complete. With traditional synchronous HTTP clients, these requests tie up application threads while waiting for responses. This gem solves that problem by using async HTTP via [PatientHttp](https://github.com/bdurand/patient_http), freeing up your threads to do other work while waiting for the LLM provider to respond.
|
|
16
|
+
|
|
17
|
+
## Prerequisites
|
|
18
|
+
|
|
19
|
+
This gem delegates actual HTTP dispatch to `patient_http`, which requires a registered request handler before any `PatientLLM.ask` call will succeed. In a normal app you get this handler by adding one of the job-system integrations:
|
|
20
|
+
|
|
21
|
+
- [patient_http-sidekiq](https://github.com/bdurand/patient_http-sidekiq)
|
|
22
|
+
- [patient_http-solid_queue](https://github.com/bdurand/patient_http-solid_queue)
|
|
23
|
+
|
|
24
|
+
Without a handler, `PatientLLM.ask` raises `RuntimeError: No request handler registered`.
|
|
25
|
+
|
|
26
|
+
## Usage
|
|
27
|
+
|
|
28
|
+
### Configuration
|
|
29
|
+
|
|
30
|
+
Register your LLM providers with their API base URLs and authentication headers:
|
|
31
|
+
|
|
32
|
+
```ruby
|
|
33
|
+
PatientLLM.configure do |config|
|
|
34
|
+
config.provider :openai,
|
|
35
|
+
url: "https://api.openai.com",
|
|
36
|
+
headers: {"Authorization" => "Bearer #{ENV["OPENAI_API_KEY"]}"}
|
|
37
|
+
|
|
38
|
+
config.provider :anthropic,
|
|
39
|
+
url: "https://api.anthropic.com",
|
|
40
|
+
headers: {"x-api-key" => ENV["ANTHROPIC_API_KEY"]},
|
|
41
|
+
serializer: :messages
|
|
42
|
+
end
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
> [!NOTE]
|
|
46
|
+
> Authentication headers configured on the provider are re-attached to every request at dispatch time and are persisted in the asynchronous job payload.
|
|
47
|
+
>
|
|
48
|
+
> You should set up encryption for you job payloads to prevent leaking credentials. See the documentation for [patient_http-sidekiq](https://github.com/bdurand/patient_http-sidekiq#sensitive-data-handling) or [patient_http-solid_queue](https://github.com/bdurand/patient_http-solid_queue#sensitive-data-handling) for details.
|
|
49
|
+
|
|
50
|
+
### Creating a Callback Class
|
|
51
|
+
|
|
52
|
+
Create a callback class with `on_complete` and `on_error` methods. Callbacks receive
|
|
53
|
+
**keyword arguments**, and you only declare the ones you need — the dispatcher inspects your
|
|
54
|
+
method signature and passes just those values (or everything if you declare `**kwargs`):
|
|
55
|
+
|
|
56
|
+
```ruby
|
|
57
|
+
class LLMCallback
|
|
58
|
+
def on_complete(session:, provider:, llm_response:, callback_args:, http_response:, request_id:)
|
|
59
|
+
# session - the PromptBuilder::Session with the response already added
|
|
60
|
+
# provider - the provider name (String)
|
|
61
|
+
# llm_response - a PromptBuilder::Response with the assistant's response
|
|
62
|
+
# callback_args - a PatientHttp::CallbackArgs containing data you passed in the `ask` call
|
|
63
|
+
# http_response - the raw PatientHttp::Response
|
|
64
|
+
# request_id - the original request id (stable across tool-call iterations)
|
|
65
|
+
|
|
66
|
+
# Access the response content
|
|
67
|
+
puts llm_response.text
|
|
68
|
+
puts "Tokens: #{llm_response.usage.input_tokens} in / #{llm_response.usage.output_tokens} out"
|
|
69
|
+
puts "Duration: #{http_response.duration}s"
|
|
70
|
+
|
|
71
|
+
# Save the session state for future turns (response is already in the session)
|
|
72
|
+
save_session_state(callback_args[:user_id], session.to_h)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def on_error(session:, provider:, callback_args:, error:, http_response:, request_id:)
|
|
76
|
+
# error is a PatientHttp::RequestError, ClientError (HTTP 4xx),
|
|
77
|
+
# or ServerError (HTTP 5xx). All respond to:
|
|
78
|
+
# error.error_type - :timeout, :connection, :ssl, :http_error, etc.
|
|
79
|
+
# error.message - human-readable message
|
|
80
|
+
# error.error_class - the original exception class (for RequestError)
|
|
81
|
+
# error.request_id
|
|
82
|
+
# http_response is the raw PatientHttp::Response for HTTP errors, or nil for
|
|
83
|
+
# transport errors (timeouts, connection failures).
|
|
84
|
+
|
|
85
|
+
log_error(error.error_type, error.message)
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
#### Callback keyword parameters
|
|
91
|
+
|
|
92
|
+
Each callback may declare any subset of the keywords below, in any order. Declaring
|
|
93
|
+
`**kwargs` receives them all. `PatientLLM.ask` validates your callback's signatures up
|
|
94
|
+
front and raises an `ArgumentError` if a method uses an unsupported name, a positional
|
|
95
|
+
parameter, or omits the required keyword.
|
|
96
|
+
|
|
97
|
+
| Callback | Supported keywords | Required |
|
|
98
|
+
|-----------------------|-----------------------------------------------------------------------------|----------------|
|
|
99
|
+
| `on_complete` | `session`, `provider`, `llm_response`, `callback_args`, `http_response`, `request_id` | `llm_response` |
|
|
100
|
+
| `on_tool_use` (optional) | `session`, `provider`, `llm_response`, `callback_args`, `http_response`, `request_id` | `llm_response` |
|
|
101
|
+
| `on_error` | `session`, `provider`, `callback_args`, `error`, `http_response`, `request_id` | `error` |
|
|
102
|
+
|
|
103
|
+
For example, a callback that only cares about the response text can be as small as:
|
|
104
|
+
|
|
105
|
+
```ruby
|
|
106
|
+
class LLMCallback
|
|
107
|
+
def on_complete(llm_response:)
|
|
108
|
+
puts llm_response.text
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def on_error(error:)
|
|
112
|
+
log_error(error.error_type, error.message)
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
### Making LLM Requests
|
|
118
|
+
|
|
119
|
+
Create a `PromptBuilder::Session` and call `PatientLLM.ask` to make an async request:
|
|
120
|
+
|
|
121
|
+
```ruby
|
|
122
|
+
session = PromptBuilder::Session.new(model: "gpt-4o")
|
|
123
|
+
session.instructions = "You are a helpful assistant."
|
|
124
|
+
session.user("What is the capital of France?")
|
|
125
|
+
|
|
126
|
+
PatientLLM.ask(session, provider: :openai, callback: LLMCallback)
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
You can pass custom data to your callback using `callback_args`:
|
|
130
|
+
|
|
131
|
+
```ruby
|
|
132
|
+
PatientLLM.ask(session, provider: :openai, callback: LLMCallback, callback_args: {
|
|
133
|
+
user_id: current_user.id,
|
|
134
|
+
conversation_id: conversation.id
|
|
135
|
+
})
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
The request is sent asynchronously. When the LLM responds, your callback's `on_complete` method will be called with the result.
|
|
139
|
+
|
|
140
|
+
### Session Configuration Options
|
|
141
|
+
|
|
142
|
+
`PromptBuilder::Session` supports various configuration:
|
|
143
|
+
|
|
144
|
+
```ruby
|
|
145
|
+
session = PromptBuilder::Session.new(model: "gpt-5.4")
|
|
146
|
+
|
|
147
|
+
# Set system instructions
|
|
148
|
+
session.instructions = "You are a helpful assistant."
|
|
149
|
+
|
|
150
|
+
# Set temperature
|
|
151
|
+
session.temperature = 0.7
|
|
152
|
+
|
|
153
|
+
# Enable reasoning for supported models (OpenAI o1/o3 family)
|
|
154
|
+
session.reasoning = {effort: "high"}
|
|
155
|
+
|
|
156
|
+
# Set a JSON schema for structured output
|
|
157
|
+
session.text = {
|
|
158
|
+
format: {
|
|
159
|
+
type: "json_schema",
|
|
160
|
+
json_schema: {
|
|
161
|
+
name: "response",
|
|
162
|
+
schema: {
|
|
163
|
+
type: "object",
|
|
164
|
+
properties: {
|
|
165
|
+
answer: { type: "string" },
|
|
166
|
+
confidence: { type: "number" }
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
# Set the maximum output tokens
|
|
174
|
+
session.max_output_tokens = 1000
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
`PatientLLM.ask` accepts additional options:
|
|
178
|
+
|
|
179
|
+
```ruby
|
|
180
|
+
PatientLLM.ask(session,
|
|
181
|
+
provider: :openai,
|
|
182
|
+
callback: LLMCallback,
|
|
183
|
+
url: "http://localhost:1234", # Override the provider's base URL
|
|
184
|
+
serializer: :messages, # Override the API format
|
|
185
|
+
completion_path: "/chat/completions", # Override the endpoint path
|
|
186
|
+
headers: {"X-Custom" => "value"}, # Additional HTTP headers
|
|
187
|
+
params: {max_completion_tokens: 1000} # Additional request parameters
|
|
188
|
+
)
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
### URL composition
|
|
192
|
+
|
|
193
|
+
The full request URL is built by concatenating the base URL (from the provider registry or the `url:` option) with the `completion_path`. When you don't set `completion_path`, it defaults to the path for the active serializer (`/v1/chat/completions` for `:chat_completion`, `/v1/responses` for `:open_responses`, `/v1/messages` for `:messages`, `/converse` for `:converse`, `/v1beta/models/{model}:generateContent` for `:gemini`). A `{model}` placeholder in the path is replaced with the session's model at dispatch time, which is how the Gemini default targets Google's `/v1beta/models/{model}:generateContent` endpoint. Trailing slashes on the base and leading slashes on the path are normalized, so:
|
|
194
|
+
|
|
195
|
+
```
|
|
196
|
+
url = "https://api.openai.com" completion_path = "/v1/chat/completions"
|
|
197
|
+
-> https://api.openai.com/v1/chat/completions
|
|
198
|
+
|
|
199
|
+
url = "http://localhost:1234" completion_path = "/v1/chat/completions"
|
|
200
|
+
-> http://localhost:1234/v1/chat/completions
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
If your base URL already includes a `/v1` prefix, override the completion path to avoid duplication:
|
|
204
|
+
|
|
205
|
+
```ruby
|
|
206
|
+
PatientLLM.ask(session,
|
|
207
|
+
provider: :openai,
|
|
208
|
+
callback: LLMCallback,
|
|
209
|
+
url: "https://my-gateway.internal/openai/v1",
|
|
210
|
+
completion_path: "/chat/completions"
|
|
211
|
+
)
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
### Tool calling
|
|
215
|
+
|
|
216
|
+
Register tools on the global `PromptBuilder.tool_registry`:
|
|
217
|
+
|
|
218
|
+
```ruby
|
|
219
|
+
PromptBuilder.tool_registry.register(
|
|
220
|
+
"weather",
|
|
221
|
+
description: "Get the current weather for a location",
|
|
222
|
+
parameters: {
|
|
223
|
+
type: "object",
|
|
224
|
+
properties: {
|
|
225
|
+
location: {type: "string", description: "City name"}
|
|
226
|
+
},
|
|
227
|
+
required: ["location"]
|
|
228
|
+
}
|
|
229
|
+
) do |args|
|
|
230
|
+
WeatherService.lookup(args["location"])
|
|
231
|
+
end
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
Then add tools to the session and ask normally:
|
|
235
|
+
|
|
236
|
+
```ruby
|
|
237
|
+
session = PromptBuilder::Session.new(model: "gpt-4o")
|
|
238
|
+
session.register_tool("weather",
|
|
239
|
+
description: "Get the current weather for a location",
|
|
240
|
+
parameters: {type: "object", properties: {location: {type: "string"}}, required: ["location"]}
|
|
241
|
+
)
|
|
242
|
+
session.user("What's the weather in NYC?")
|
|
243
|
+
|
|
244
|
+
PatientLLM.ask(session, provider: :openai, callback: LLMCallback)
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
When the model responds with tool calls, the gem automatically:
|
|
248
|
+
|
|
249
|
+
1. Appends the assistant tool-call response to the session.
|
|
250
|
+
2. Invokes the matching tool handler from the registry with the LLM-provided arguments.
|
|
251
|
+
3. Appends a tool-response item to the session.
|
|
252
|
+
4. Re-issues the request asynchronously.
|
|
253
|
+
5. Repeats until the model returns a plain text response (or a tool raises `HaltError`). Your `on_complete` callback only fires for the final text response.
|
|
254
|
+
|
|
255
|
+
If you define an optional `on_tool_use` method on your callback, it is invoked once per tool-execution round (after the tools run, before the next request is issued) so you can observe intermediate progress.
|
|
256
|
+
|
|
257
|
+
The loop is capped at `PatientLLM::Callback::MAX_TOOL_ITERATIONS` (10) iterations per conversation to prevent runaway calls. When the cap is exceeded, your `on_error` callback is invoked with a `PatientHttp::RequestError` whose `error_type` is `:max_tool_iterations` and whose `error_class` is `PatientLLM::MaxToolIterationsError`, so you can handle it alongside transport and HTTP errors.
|
|
258
|
+
|
|
259
|
+
> [!NOTE]
|
|
260
|
+
> Tool handlers execute synchronously inside the callback worker (e.g. a Sidekiq job). Keep handlers fast to avoid blocking the worker pool. If a tool needs to do slow work (external API calls, heavy queries), consider offloading that work and using `HaltError` to stop the auto-loop.
|
|
261
|
+
|
|
262
|
+
#### Halting the loop
|
|
263
|
+
|
|
264
|
+
Raise `PatientLLM::HaltError` from a tool handler to stop the auto-loop and surface custom content as the final assistant message:
|
|
265
|
+
|
|
266
|
+
```ruby
|
|
267
|
+
PromptBuilder.tool_registry.register("auth", description: "Authenticate", parameters: {...}) do |args|
|
|
268
|
+
unless AuthService.valid?(args["token"])
|
|
269
|
+
raise PatientLLM::HaltError.new(content: "Authentication failed.")
|
|
270
|
+
end
|
|
271
|
+
AuthService.session_info(args["token"])
|
|
272
|
+
end
|
|
273
|
+
```
|
|
274
|
+
|
|
275
|
+
### Serializing Conversations
|
|
276
|
+
|
|
277
|
+
Sessions can be serialized to JSON for storage and later restored:
|
|
278
|
+
|
|
279
|
+
```ruby
|
|
280
|
+
# Initial request
|
|
281
|
+
session = PromptBuilder::Session.new(model: "gpt-4o")
|
|
282
|
+
session.instructions = "You are a helpful assistant."
|
|
283
|
+
session.user("Hello!")
|
|
284
|
+
|
|
285
|
+
PatientLLM.ask(session, provider: :openai, callback: LLMCallback,
|
|
286
|
+
callback_args: {conversation_id: conversation.id})
|
|
287
|
+
|
|
288
|
+
# In your callback, save the state (response is already in the session):
|
|
289
|
+
def on_complete(session:, callback_args:, **)
|
|
290
|
+
save_to_database(callback_args[:conversation_id], session.to_h)
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
# Later, restore and continue:
|
|
294
|
+
session_data = load_from_database(conversation_id)
|
|
295
|
+
session = PromptBuilder::Session.from_h(session_data)
|
|
296
|
+
session.user("Tell me more about that.")
|
|
297
|
+
|
|
298
|
+
PatientLLM.ask(session, provider: :openai, callback: LLMCallback,
|
|
299
|
+
callback_args: {conversation_id: conversation_id})
|
|
300
|
+
```
|
|
301
|
+
|
|
302
|
+
## Installation
|
|
303
|
+
|
|
304
|
+
This gem is not yet published to RubyGems. Add it from GitHub:
|
|
305
|
+
|
|
306
|
+
```ruby
|
|
307
|
+
gem "patient_llm", github: "bdurand/patient_llm"
|
|
308
|
+
```
|
|
309
|
+
|
|
310
|
+
Then execute:
|
|
311
|
+
```bash
|
|
312
|
+
$ bundle
|
|
313
|
+
```
|
|
314
|
+
|
|
315
|
+
## Contributing
|
|
316
|
+
|
|
317
|
+
Open a pull request on [GitHub](https://github.com/bdurand/patient_llm).
|
|
318
|
+
|
|
319
|
+
Please use the [standardrb](https://github.com/testdouble/standard) syntax and lint your code with `standardrb --fix` before submitting.
|
|
320
|
+
|
|
321
|
+
## License
|
|
322
|
+
|
|
323
|
+
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
data/VERSION
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
0.1.0
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module PatientLLM
|
|
6
|
+
# Callback class that receives async HTTP responses from PatientHttp and
|
|
7
|
+
# dispatches to the user's callback.
|
|
8
|
+
#
|
|
9
|
+
# When the response contains tool calls and the global PromptBuilder tool
|
|
10
|
+
# registry has handlers for those tools, this class executes them
|
|
11
|
+
# automatically and re-issues the request until the model returns a final
|
|
12
|
+
# text response or a tool raises {HaltError}. Iteration is capped at
|
|
13
|
+
# {MAX_TOOL_ITERATIONS} to prevent runaway loops.
|
|
14
|
+
#
|
|
15
|
+
# The user callback receives a `PromptBuilder::Response` object. Access
|
|
16
|
+
# the response text via `response.text`, token usage via `response.usage`,
|
|
17
|
+
# and model id via `response.model`.
|
|
18
|
+
class Callback
|
|
19
|
+
# Maximum number of tool-execution rounds before the loop raises.
|
|
20
|
+
MAX_TOOL_ITERATIONS = 10
|
|
21
|
+
|
|
22
|
+
# Supported keyword parameters for each user callback method, along with the
|
|
23
|
+
# one parameter that must always be declared.
|
|
24
|
+
CALLBACK_PARAMS = {
|
|
25
|
+
on_complete: {allowed: %i[session provider llm_response callback_args http_response request_id]},
|
|
26
|
+
on_tool_use: {allowed: %i[session provider llm_response callback_args http_response request_id]},
|
|
27
|
+
on_error: {allowed: %i[session provider callback_args error http_response request_id], required: :error}
|
|
28
|
+
}.freeze
|
|
29
|
+
|
|
30
|
+
# Validate that a user callback class declares supported keyword parameters.
|
|
31
|
+
#
|
|
32
|
+
# Each defined callback method must use keyword parameters drawn from the
|
|
33
|
+
# supported set for that method and must declare the required parameter
|
|
34
|
+
# (`error` is required for on_error). A
|
|
35
|
+
# `**kwargs` splat is permitted and receives every available value.
|
|
36
|
+
#
|
|
37
|
+
# @param callback_class [Class] The user callback class
|
|
38
|
+
# @raise [ArgumentError] If a method uses positional or unsupported parameters
|
|
39
|
+
# @return [void]
|
|
40
|
+
def self.validate_callback_class!(callback_class)
|
|
41
|
+
CALLBACK_PARAMS.each do |method_name, spec|
|
|
42
|
+
next unless callback_class.method_defined?(method_name)
|
|
43
|
+
|
|
44
|
+
params = callback_class.instance_method(method_name).parameters
|
|
45
|
+
splat = params.any? { |type, _| type == :keyrest }
|
|
46
|
+
declared = []
|
|
47
|
+
params.each do |type, name|
|
|
48
|
+
case type
|
|
49
|
+
when :key, :keyreq
|
|
50
|
+
declared << name
|
|
51
|
+
when :keyrest, :block
|
|
52
|
+
next
|
|
53
|
+
else
|
|
54
|
+
raise ArgumentError, "#{callback_class}##{method_name} must use keyword parameters; found positional parameter #{name.inspect}"
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
allowed = Array(spec[:allowed])
|
|
59
|
+
unknown = declared - allowed
|
|
60
|
+
unless unknown.empty?
|
|
61
|
+
raise ArgumentError, "#{callback_class}##{method_name} has unsupported parameter(s): #{unknown.map(&:inspect).join(", ")}. Allowed: #{allowed.map(&:inspect).join(", ")}"
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
required = Array(spec[:required])
|
|
65
|
+
unless splat || required.empty? || (required & declared).any?
|
|
66
|
+
raise ArgumentError, "#{callback_class}##{method_name} must declare the #{required.map(&:inspect).join(", ")} keyword parameter(s)"
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Handle a successful LLM completion response.
|
|
72
|
+
#
|
|
73
|
+
# @param response [PatientHttp::Response] The async HTTP response
|
|
74
|
+
# @return [void]
|
|
75
|
+
def on_complete(response)
|
|
76
|
+
callback_args = response.callback_args
|
|
77
|
+
session = restore_session(callback_args)
|
|
78
|
+
provider_name = callback_args[:provider]
|
|
79
|
+
request_options = callback_args[:request_options] || {}
|
|
80
|
+
user_callback = resolve_user_callback(callback_args)
|
|
81
|
+
original_request_id = callback_args.fetch(:original_request_id, nil) || response.request_id
|
|
82
|
+
|
|
83
|
+
serializer = resolve_serializer(provider_name, request_options)
|
|
84
|
+
llm_response = PromptBuilder::Response.parse(response.json, serializer)
|
|
85
|
+
|
|
86
|
+
if should_auto_execute_tools?(llm_response)
|
|
87
|
+
continue_tool_loop(session, provider_name, llm_response, callback_args, response, user_callback, request_options, original_request_id)
|
|
88
|
+
else
|
|
89
|
+
session.add_response(llm_response)
|
|
90
|
+
invoke_user_callback(user_callback, :on_complete, session: session, provider: provider_name, llm_response: llm_response, callback_args: user_callback_args(callback_args), http_response: response, request_id: original_request_id)
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Handle an error during an LLM request.
|
|
95
|
+
#
|
|
96
|
+
# @param error [PatientHttp::Error] The error
|
|
97
|
+
# @return [void]
|
|
98
|
+
def on_error(error)
|
|
99
|
+
callback_args = error.callback_args
|
|
100
|
+
session = restore_session(callback_args)
|
|
101
|
+
provider_name = callback_args[:provider]
|
|
102
|
+
http_response = error.respond_to?(:response) ? error.response : nil
|
|
103
|
+
original_request_id = callback_args.fetch(:original_request_id, http_response&.request_id)
|
|
104
|
+
user_callback = resolve_user_callback(callback_args)
|
|
105
|
+
invoke_user_callback(user_callback, :on_error, session: session, provider: provider_name, callback_args: user_callback_args(callback_args), error: error, http_response: http_response, request_id: original_request_id)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
private
|
|
109
|
+
|
|
110
|
+
def invoke_user_callback(user_callback, method_name, **values)
|
|
111
|
+
params = user_callback.method(method_name).parameters
|
|
112
|
+
kwargs =
|
|
113
|
+
if params.any? { |type, _| type == :keyrest || type == :rest }
|
|
114
|
+
values
|
|
115
|
+
else
|
|
116
|
+
names = params.map { |_, name| name }
|
|
117
|
+
values.slice(*names)
|
|
118
|
+
end
|
|
119
|
+
user_callback.public_send(method_name, **kwargs)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def restore_session(callback_args)
|
|
123
|
+
session_hash = callback_args.fetch(:session, {})
|
|
124
|
+
PromptBuilder::Session.from_h(session_hash)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def resolve_user_callback(callback_args)
|
|
128
|
+
class_name = callback_args.fetch(:callback, nil)
|
|
129
|
+
if class_name.nil? || class_name == ""
|
|
130
|
+
raise ArgumentError, "No callback registered"
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
callback_class = PatientHttp::ClassHelper.resolve_class_name(class_name)
|
|
134
|
+
callback_class.new
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def user_callback_args(callback_args)
|
|
138
|
+
PatientHttp::CallbackArgs.new(callback_args[:custom] || {})
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def resolve_serializer(provider_name, request_options)
|
|
142
|
+
if request_options["serializer"] && !request_options["serializer"].empty?
|
|
143
|
+
return request_options["serializer"].to_sym
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
provider_config = PatientLLM.provider(provider_name)
|
|
147
|
+
provider_config&.dig(:serializer) || :chat_completion
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def should_auto_execute_tools?(llm_response)
|
|
151
|
+
return false unless llm_response.has_tool_calls?
|
|
152
|
+
|
|
153
|
+
llm_response.tool_calls.any? do |call|
|
|
154
|
+
PromptBuilder.tool_registry.handler_for(call.name)
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def continue_tool_loop(session, provider_name, llm_response, callback_args, http_response, user_callback, request_options, original_request_id)
|
|
159
|
+
iteration = callback_args.fetch(:tool_iteration, 0).to_i
|
|
160
|
+
if iteration >= MAX_TOOL_ITERATIONS
|
|
161
|
+
error = max_tool_iterations_error(http_response, original_request_id)
|
|
162
|
+
invoke_user_callback(user_callback, :on_error, session: session, provider: provider_name, callback_args: user_callback_args(callback_args), error: error, http_response: http_response, request_id: original_request_id)
|
|
163
|
+
return
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
session.add_response(llm_response)
|
|
167
|
+
|
|
168
|
+
halt = nil
|
|
169
|
+
llm_response.tool_calls.each do |function_call|
|
|
170
|
+
result, halted = execute_tool(function_call)
|
|
171
|
+
halt = halted if halted
|
|
172
|
+
|
|
173
|
+
session.add_item(
|
|
174
|
+
PromptBuilder::Items::FunctionCallOutput.new(
|
|
175
|
+
call_id: function_call.call_id,
|
|
176
|
+
output: result
|
|
177
|
+
)
|
|
178
|
+
)
|
|
179
|
+
break if halt
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
if halt
|
|
183
|
+
content = halt.content
|
|
184
|
+
halt_response = PromptBuilder::Response.new(
|
|
185
|
+
model: llm_response.model,
|
|
186
|
+
status: "completed",
|
|
187
|
+
output: [
|
|
188
|
+
PromptBuilder::Items::Message.new(
|
|
189
|
+
role: "assistant",
|
|
190
|
+
content: [PromptBuilder::Content::OutputText.new(text: content || "")]
|
|
191
|
+
)
|
|
192
|
+
],
|
|
193
|
+
usage: llm_response.usage
|
|
194
|
+
)
|
|
195
|
+
session.add_response(halt_response)
|
|
196
|
+
invoke_user_callback(user_callback, :on_complete, session: session, provider: provider_name, llm_response: halt_response, callback_args: user_callback_args(callback_args), http_response: http_response, request_id: original_request_id)
|
|
197
|
+
return
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
if user_callback.respond_to?(:on_tool_use)
|
|
201
|
+
invoke_user_callback(user_callback, :on_tool_use, session: session, provider: provider_name, llm_response: llm_response, callback_args: user_callback_args(callback_args), http_response: http_response, request_id: original_request_id)
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# Re-ask with the updated session
|
|
205
|
+
ask_kwargs = {
|
|
206
|
+
provider: provider_name.to_sym,
|
|
207
|
+
callback: callback_args[:callback],
|
|
208
|
+
callback_args: callback_args[:custom],
|
|
209
|
+
tool_iteration: iteration + 1,
|
|
210
|
+
original_request_id: original_request_id
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
# Restore per-request overrides
|
|
214
|
+
ask_kwargs[:url] = request_options["url"] if request_options["url"]
|
|
215
|
+
ask_kwargs[:serializer] = request_options["serializer"].to_sym if request_options["serializer"]
|
|
216
|
+
ask_kwargs[:completion_path] = request_options["completion_path"] if request_options["completion_path"]
|
|
217
|
+
ask_kwargs[:headers] = request_options["headers"] if request_options["headers"]
|
|
218
|
+
ask_kwargs[:params] = request_options["params"] if request_options["params"]
|
|
219
|
+
|
|
220
|
+
PatientLLM.ask(session, **ask_kwargs)
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def max_tool_iterations_error(http_response, request_id)
|
|
224
|
+
exception = MaxToolIterationsError.new("Tool-call loop exceeded #{MAX_TOOL_ITERATIONS} iterations")
|
|
225
|
+
PatientHttp::RequestError.new(
|
|
226
|
+
class_name: exception.class.name,
|
|
227
|
+
message: exception.message,
|
|
228
|
+
backtrace: [],
|
|
229
|
+
error_type: :max_tool_iterations,
|
|
230
|
+
duration: http_response&.duration,
|
|
231
|
+
request_id: request_id,
|
|
232
|
+
url: http_response&.url,
|
|
233
|
+
http_method: http_response&.http_method
|
|
234
|
+
)
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
def execute_tool(function_call)
|
|
238
|
+
name = function_call.name
|
|
239
|
+
args = function_call.parsed_arguments
|
|
240
|
+
|
|
241
|
+
result = PromptBuilder.tool_registry.invoke(name, args)
|
|
242
|
+
[format_result(result), nil]
|
|
243
|
+
rescue HaltError => e
|
|
244
|
+
[format_result(e.content), e]
|
|
245
|
+
rescue PromptBuilder::ToolNotFoundError => e
|
|
246
|
+
[e.message, nil]
|
|
247
|
+
rescue => e
|
|
248
|
+
["Error executing tool #{function_call.name}: #{e.class}: #{e.message}", nil]
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
def format_result(result)
|
|
252
|
+
case result
|
|
253
|
+
when String then result
|
|
254
|
+
when nil then ""
|
|
255
|
+
else
|
|
256
|
+
JSON.generate(result)
|
|
257
|
+
end
|
|
258
|
+
end
|
|
259
|
+
end
|
|
260
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PatientLLM
|
|
4
|
+
# Configuration for provider registry.
|
|
5
|
+
#
|
|
6
|
+
# @example
|
|
7
|
+
# PatientLLM.configure do |config|
|
|
8
|
+
# config.provider :openai,
|
|
9
|
+
# url: "https://api.openai.com",
|
|
10
|
+
# headers: {"Authorization" => "Bearer #{ENV["OPENAI_API_KEY"]}"},
|
|
11
|
+
# serializer: :chat_completion
|
|
12
|
+
# end
|
|
13
|
+
class Configuration
|
|
14
|
+
def initialize
|
|
15
|
+
@providers = {}
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Register a provider with a base URL and default headers.
|
|
19
|
+
#
|
|
20
|
+
# @param name [Symbol, String] Provider name
|
|
21
|
+
# @param url [String] Base URL for the provider API
|
|
22
|
+
# @param headers [Hash] Default headers for requests
|
|
23
|
+
# @param serializer [Symbol] API format (:chat_completion, :open_responses, :messages, :converse, :gemini)
|
|
24
|
+
# @param completion_path [String, nil] Override the default endpoint path
|
|
25
|
+
# @param params [Hash] Additional parameters to merge into every request payload
|
|
26
|
+
# @return [void]
|
|
27
|
+
def provider(name, url:, headers: {}, serializer: :chat_completion, completion_path: nil, params: {})
|
|
28
|
+
sym = serializer.to_sym
|
|
29
|
+
unless PatientLLM::VALID_SERIALIZERS.include?(sym)
|
|
30
|
+
raise ArgumentError, "Unknown serializer: #{sym.inspect}. Valid options: #{PatientLLM::VALID_SERIALIZERS.map(&:inspect).join(", ")}"
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
@providers[name.to_s] = {
|
|
34
|
+
url: url,
|
|
35
|
+
headers: headers,
|
|
36
|
+
serializer: sym,
|
|
37
|
+
completion_path: completion_path,
|
|
38
|
+
params: params
|
|
39
|
+
}
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Look up a registered provider by name.
|
|
43
|
+
#
|
|
44
|
+
# @param name [Symbol, String] Provider name
|
|
45
|
+
# @return [Hash, nil] Provider config hash
|
|
46
|
+
def lookup(name)
|
|
47
|
+
@providers[name&.to_s]
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PatientLLM
|
|
4
|
+
# Raised by a tool handler to short-circuit the auto-execution loop.
|
|
5
|
+
# The +content+ is delivered to the user callback as the final assistant
|
|
6
|
+
# message.
|
|
7
|
+
#
|
|
8
|
+
# @example
|
|
9
|
+
# PromptBuilder.register_tool("stop", description: "Stop") do |args|
|
|
10
|
+
# raise PatientLLM::HaltError.new(content: "Done!")
|
|
11
|
+
# end
|
|
12
|
+
class HaltError < StandardError
|
|
13
|
+
# @return [String, nil] Content to surface as the assistant response
|
|
14
|
+
attr_reader :content
|
|
15
|
+
|
|
16
|
+
# @param content [String, nil] The content for the final assistant message
|
|
17
|
+
# @param message [String] Optional error message (defaults to "Tool halted execution")
|
|
18
|
+
def initialize(content: nil, message: "Tool halted execution")
|
|
19
|
+
@content = content
|
|
20
|
+
super(message)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PatientLLM
|
|
4
|
+
# Raised internally when the automatic tool-execution loop exceeds
|
|
5
|
+
# {Callback::MAX_TOOL_ITERATIONS}. It is wrapped in a
|
|
6
|
+
# `PatientHttp::RequestError` (with error type +:max_tool_iterations+) and
|
|
7
|
+
# delivered to the user callback's +on_error+ method rather than propagating
|
|
8
|
+
# out of the callback worker.
|
|
9
|
+
class MaxToolIterationsError < StandardError
|
|
10
|
+
end
|
|
11
|
+
end
|
data/lib/patient_llm.rb
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "patient_http"
|
|
4
|
+
require "prompt_builder"
|
|
5
|
+
|
|
6
|
+
module PatientLLM
|
|
7
|
+
VERSION = File.read(File.join(__dir__, "../VERSION")).strip
|
|
8
|
+
|
|
9
|
+
autoload :Configuration, File.expand_path("patient_llm/configuration", __dir__)
|
|
10
|
+
autoload :HaltError, File.expand_path("patient_llm/halt_error", __dir__)
|
|
11
|
+
autoload :MaxToolIterationsError, File.expand_path("patient_llm/max_tool_iterations_error", __dir__)
|
|
12
|
+
autoload :Callback, File.expand_path("patient_llm/callback", __dir__)
|
|
13
|
+
|
|
14
|
+
# Default API paths per serializer format. The Gemini path embeds a
|
|
15
|
+
# `{model}` placeholder that is replaced with the session's model at
|
|
16
|
+
# dispatch time, matching Google's `/v1beta/models/{model}:generateContent`
|
|
17
|
+
# endpoint.
|
|
18
|
+
SERIALIZER_PATHS = {
|
|
19
|
+
chat_completion: "/v1/chat/completions",
|
|
20
|
+
open_responses: "/v1/responses",
|
|
21
|
+
messages: "/v1/messages",
|
|
22
|
+
converse: "/converse",
|
|
23
|
+
gemini: "/v1beta/models/{model}:generateContent"
|
|
24
|
+
}.freeze
|
|
25
|
+
|
|
26
|
+
# Required version header for the Anthropic Messages API.
|
|
27
|
+
ANTHROPIC_VERSION = "2023-06-01"
|
|
28
|
+
|
|
29
|
+
# Valid serializer format names.
|
|
30
|
+
VALID_SERIALIZERS = SERIALIZER_PATHS.keys.freeze
|
|
31
|
+
|
|
32
|
+
class << self
|
|
33
|
+
# Configure providers for LLM requests.
|
|
34
|
+
#
|
|
35
|
+
# @yield [Configuration]
|
|
36
|
+
# @return [void]
|
|
37
|
+
def configure
|
|
38
|
+
@configuration ||= Configuration.new
|
|
39
|
+
yield @configuration
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Reset configuration. Primarily useful in tests.
|
|
43
|
+
#
|
|
44
|
+
# @return [void]
|
|
45
|
+
def reset!
|
|
46
|
+
@configuration = nil
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Look up a registered provider by name.
|
|
50
|
+
#
|
|
51
|
+
# @param name [Symbol, String] Provider name
|
|
52
|
+
# @return [Hash, nil] Provider config
|
|
53
|
+
def provider(name)
|
|
54
|
+
@configuration&.lookup(name)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Send an LLM request asynchronously using the given session and provider.
|
|
58
|
+
#
|
|
59
|
+
# @param session [PromptBuilder::Session] The prompt session containing conversation state
|
|
60
|
+
# @param provider [Symbol, String] Registered provider name
|
|
61
|
+
# @param callback [Class, String] Callback class for handling completion/error
|
|
62
|
+
# @param callback_args [Hash] Custom arguments passed through to the callback
|
|
63
|
+
# @param url [String, nil] Override the provider's base URL for this request
|
|
64
|
+
# @param serializer [Symbol, nil] Override the provider's serializer for this request
|
|
65
|
+
# @param completion_path [String, nil] Override the endpoint path for this request
|
|
66
|
+
# @param headers [Hash, nil] Additional headers merged on top of provider headers
|
|
67
|
+
# @param params [Hash, nil] Additional params merged into the request payload
|
|
68
|
+
# @return [Object] Handler-specific identifier for the enqueued request
|
|
69
|
+
def ask(session, provider:, callback:, callback_args: {}, url: nil, serializer: nil, completion_path: nil, headers: nil, params: nil, tool_iteration: 0, original_request_id: nil) # :nodoc: tool_iteration and original_request_id are internal
|
|
70
|
+
provider_config = self.provider(provider) || {}
|
|
71
|
+
provider_name = provider.to_s
|
|
72
|
+
|
|
73
|
+
if tool_iteration.zero?
|
|
74
|
+
PatientLLM::Callback.validate_callback_class!(PatientHttp::ClassHelper.resolve_class_name(callback.to_s))
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
resolved_url = url || provider_config[:url]
|
|
78
|
+
raise ArgumentError, "No API base URL configured. Set url: or register a provider with a url." unless resolved_url
|
|
79
|
+
|
|
80
|
+
resolved_serializer = (serializer || provider_config[:serializer] || :chat_completion).to_sym
|
|
81
|
+
validate_serializer!(resolved_serializer)
|
|
82
|
+
resolved_completion_path = completion_path || provider_config[:completion_path] || SERIALIZER_PATHS[resolved_serializer] || "/v1/chat/completions"
|
|
83
|
+
if resolved_completion_path.include?("{model}")
|
|
84
|
+
resolved_completion_path = resolved_completion_path.gsub("{model}", session.model.to_s)
|
|
85
|
+
end
|
|
86
|
+
resolved_headers = (provider_config[:headers] || {}).merge(headers || {})
|
|
87
|
+
if resolved_serializer == :messages && !resolved_headers.key?("anthropic-version")
|
|
88
|
+
resolved_headers = {"anthropic-version" => ANTHROPIC_VERSION}.merge(resolved_headers)
|
|
89
|
+
end
|
|
90
|
+
resolved_params = (provider_config[:params] || {}).merge(params || {})
|
|
91
|
+
|
|
92
|
+
payload = session.request_payload(resolved_serializer)
|
|
93
|
+
payload = deep_merge(payload, deep_stringify_keys(resolved_params)) unless resolved_params.empty?
|
|
94
|
+
|
|
95
|
+
request_url = join_url(resolved_url, resolved_completion_path)
|
|
96
|
+
|
|
97
|
+
request_options = {}
|
|
98
|
+
request_options["url"] = url if url
|
|
99
|
+
request_options["serializer"] = serializer.to_s if serializer
|
|
100
|
+
request_options["completion_path"] = completion_path if completion_path
|
|
101
|
+
request_options["headers"] = headers if headers && !headers.empty?
|
|
102
|
+
request_options["params"] = params if params && !params.empty?
|
|
103
|
+
|
|
104
|
+
PatientHttp.post(
|
|
105
|
+
request_url,
|
|
106
|
+
json: payload,
|
|
107
|
+
headers: resolved_headers,
|
|
108
|
+
raise_error_responses: true,
|
|
109
|
+
callback: PatientLLM::Callback,
|
|
110
|
+
callback_args: {
|
|
111
|
+
session: session.to_h,
|
|
112
|
+
provider: provider_name,
|
|
113
|
+
callback: callback.to_s,
|
|
114
|
+
custom: callback_args.transform_keys(&:to_s),
|
|
115
|
+
request_options: request_options,
|
|
116
|
+
tool_iteration: tool_iteration,
|
|
117
|
+
original_request_id: original_request_id
|
|
118
|
+
}
|
|
119
|
+
)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
private
|
|
123
|
+
|
|
124
|
+
def validate_serializer!(serializer)
|
|
125
|
+
unless VALID_SERIALIZERS.include?(serializer)
|
|
126
|
+
raise ArgumentError, "Unknown serializer: #{serializer.inspect}. Valid options: #{VALID_SERIALIZERS.map(&:inspect).join(", ")}"
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def join_url(base, path)
|
|
131
|
+
"#{base.sub(%r{/\z}, "")}/#{path.to_s.sub(%r{\A/}, "")}"
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def deep_merge(hash1, hash2)
|
|
135
|
+
hash1.merge(hash2) do |_key, old_val, new_val|
|
|
136
|
+
if old_val.is_a?(Hash) && new_val.is_a?(Hash)
|
|
137
|
+
deep_merge(old_val, new_val)
|
|
138
|
+
else
|
|
139
|
+
new_val
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def deep_stringify_keys(hash)
|
|
145
|
+
return {} if hash.nil?
|
|
146
|
+
hash.each_with_object({}) do |(k, v), acc|
|
|
147
|
+
acc[k.to_s] = v.is_a?(Hash) ? deep_stringify_keys(v) : v
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|
data/patient_llm.gemspec
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
Gem::Specification.new do |spec|
|
|
2
|
+
spec.name = "patient_llm"
|
|
3
|
+
spec.version = File.read(File.expand_path("../VERSION", __FILE__)).strip
|
|
4
|
+
spec.authors = ["Brian Durand"]
|
|
5
|
+
spec.email = ["bbdurand@gmail.com"]
|
|
6
|
+
|
|
7
|
+
spec.summary = "Asynchronous LLM API requests via patient_http using prompt_builder for multi-format LLM API support."
|
|
8
|
+
|
|
9
|
+
spec.homepage = "https://github.com/bdurand/patient_llm"
|
|
10
|
+
spec.license = "MIT"
|
|
11
|
+
|
|
12
|
+
spec.metadata = {
|
|
13
|
+
"homepage_uri" => spec.homepage,
|
|
14
|
+
"source_code_uri" => spec.homepage,
|
|
15
|
+
"changelog_uri" => "#{spec.homepage}/blob/main/CHANGELOG.md"
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
# Specify which files should be added to the gem when it is released.
|
|
19
|
+
# The `git ls-files -z` loads the files in the RubyGem that have been added into git.
|
|
20
|
+
ignore_files = %w[
|
|
21
|
+
.
|
|
22
|
+
AGENTS.md
|
|
23
|
+
Appraisals
|
|
24
|
+
Gemfile
|
|
25
|
+
Gemfile.lock
|
|
26
|
+
Rakefile
|
|
27
|
+
bin/
|
|
28
|
+
gemfiles/
|
|
29
|
+
spec/
|
|
30
|
+
test_app/
|
|
31
|
+
]
|
|
32
|
+
spec.files = Dir.chdir(File.expand_path("..", __FILE__)) do
|
|
33
|
+
`git ls-files -z`.split("\x0").reject { |f| ignore_files.any? { |path| f.start_with?(path) } }
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
spec.require_paths = ["lib"]
|
|
37
|
+
|
|
38
|
+
spec.required_ruby_version = ">= 3.0"
|
|
39
|
+
|
|
40
|
+
spec.add_dependency "patient_http"
|
|
41
|
+
spec.add_dependency "prompt_builder"
|
|
42
|
+
|
|
43
|
+
spec.add_development_dependency "bundler"
|
|
44
|
+
spec.add_development_dependency "rspec", "~> 3.12"
|
|
45
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: patient_llm
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Brian Durand
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: patient_http
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - ">="
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '0'
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - ">="
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '0'
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: prompt_builder
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - ">="
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '0'
|
|
33
|
+
type: :runtime
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - ">="
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '0'
|
|
40
|
+
- !ruby/object:Gem::Dependency
|
|
41
|
+
name: bundler
|
|
42
|
+
requirement: !ruby/object:Gem::Requirement
|
|
43
|
+
requirements:
|
|
44
|
+
- - ">="
|
|
45
|
+
- !ruby/object:Gem::Version
|
|
46
|
+
version: '0'
|
|
47
|
+
type: :development
|
|
48
|
+
prerelease: false
|
|
49
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
50
|
+
requirements:
|
|
51
|
+
- - ">="
|
|
52
|
+
- !ruby/object:Gem::Version
|
|
53
|
+
version: '0'
|
|
54
|
+
- !ruby/object:Gem::Dependency
|
|
55
|
+
name: rspec
|
|
56
|
+
requirement: !ruby/object:Gem::Requirement
|
|
57
|
+
requirements:
|
|
58
|
+
- - "~>"
|
|
59
|
+
- !ruby/object:Gem::Version
|
|
60
|
+
version: '3.12'
|
|
61
|
+
type: :development
|
|
62
|
+
prerelease: false
|
|
63
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
64
|
+
requirements:
|
|
65
|
+
- - "~>"
|
|
66
|
+
- !ruby/object:Gem::Version
|
|
67
|
+
version: '3.12'
|
|
68
|
+
email:
|
|
69
|
+
- bbdurand@gmail.com
|
|
70
|
+
executables: []
|
|
71
|
+
extensions: []
|
|
72
|
+
extra_rdoc_files: []
|
|
73
|
+
files:
|
|
74
|
+
- CHANGELOG.md
|
|
75
|
+
- MIT-LICENSE
|
|
76
|
+
- README.md
|
|
77
|
+
- VERSION
|
|
78
|
+
- lib/patient_llm.rb
|
|
79
|
+
- lib/patient_llm/callback.rb
|
|
80
|
+
- lib/patient_llm/configuration.rb
|
|
81
|
+
- lib/patient_llm/halt_error.rb
|
|
82
|
+
- lib/patient_llm/max_tool_iterations_error.rb
|
|
83
|
+
- patient_llm.gemspec
|
|
84
|
+
homepage: https://github.com/bdurand/patient_llm
|
|
85
|
+
licenses:
|
|
86
|
+
- MIT
|
|
87
|
+
metadata:
|
|
88
|
+
homepage_uri: https://github.com/bdurand/patient_llm
|
|
89
|
+
source_code_uri: https://github.com/bdurand/patient_llm
|
|
90
|
+
changelog_uri: https://github.com/bdurand/patient_llm/blob/main/CHANGELOG.md
|
|
91
|
+
rdoc_options: []
|
|
92
|
+
require_paths:
|
|
93
|
+
- lib
|
|
94
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
95
|
+
requirements:
|
|
96
|
+
- - ">="
|
|
97
|
+
- !ruby/object:Gem::Version
|
|
98
|
+
version: '3.0'
|
|
99
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
100
|
+
requirements:
|
|
101
|
+
- - ">="
|
|
102
|
+
- !ruby/object:Gem::Version
|
|
103
|
+
version: '0'
|
|
104
|
+
requirements: []
|
|
105
|
+
rubygems_version: 4.0.3
|
|
106
|
+
specification_version: 4
|
|
107
|
+
summary: Asynchronous LLM API requests via patient_http using prompt_builder for multi-format
|
|
108
|
+
LLM API support.
|
|
109
|
+
test_files: []
|