patient_llm 0.5.1 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f1b92ba6cfe7609461cc940064399ce5a9c17f79ad18b5beaea3ae33ef9bfb11
4
- data.tar.gz: c3420d27801ab7571685e6d3fde742ed7203ecb01a068a89749c07c6511fc11b
3
+ metadata.gz: a477a6fc932f1ad07d9dae5c06bc71e3ebac003a10b155400076172a7f0a67fc
4
+ data.tar.gz: 6d11574edcede56eb37658e36f94fe2f45aa693fc058508c8058eeab3df6429b
5
5
  SHA512:
6
- metadata.gz: eab9baa8560c32b4af7cdda7c1f72b13701f86c7295e3c2b5ae990b073707f69687e207b9908672e8dc78a6d0134fe829f20b3d40cb9d3b3c2bc5197b53c071a
7
- data.tar.gz: 826f52f65cf57779edcbb1ee5dcdb8be254cff69d509eaa30b25e799a955db4f5c5413160b535c2169906a62f0e6d7bfda55589f7a7273a2003a431de49cdc25
6
+ metadata.gz: eb0cde4a1466a1a885f78b0a57c3ec50124650672cee430d13818d0daa3b3ddb0baf1fe3b326165ddf43d814e6c458eceb8ff4d9b24c21d33902b861bcd1f3f3
7
+ data.tar.gz: 881b6d03088e1d3880597fb3fecb87b5e53885846d39f61548394ef2cd68ced822f3dd7e6514061d522942ed7fe084f0e8c86ec8752d9233cb206218fe783a28
data/CHANGELOG.md CHANGED
@@ -4,6 +4,12 @@ All notable changes to this project will be documented in this file.
4
4
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
5
5
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
+ ## 0.6.0
8
+
9
+ ### Added
10
+
11
+ - Added `PatientLLM.preview_request` and `PatientLLM::Agent.preview_request` to build the request that `ask` would send without sending it. Both return a `PatientLLM::RequestPreview` with the resolved `url`, `headers`, and JSON `payload`, produced by the same resolution logic dispatch uses. Nothing is enqueued or executed, header values that reference registered secrets are replaced with placeholders, and request preprocessors (which run at send time) are not applied. `Agent.preview_request` accepts the same options as `ask`, ignoring `context:` and `callback:` since they do not affect the request.
12
+
7
13
  ## 0.5.1
8
14
 
9
15
  ### Added
data/README.md CHANGED
@@ -364,6 +364,16 @@ response = ResearchAgent.ask!("What is the capital of France?")
364
364
  response.text # => "Paris."
365
365
  ```
366
366
 
367
+ ### Previewing requests
368
+
369
+ `preview_request` builds the request that `ask` would send — through the same resolution logic — without sending anything. It returns a `PatientLLM::RequestPreview` with the resolved `url`, `headers`, and JSON `payload`. Header values that reference registered secrets are replaced with placeholders, and request preprocessors (which run at send time) are not applied:
370
+
371
+ ```ruby
372
+ preview = ResearchAgent.preview_request("What is the capital of France?")
373
+ preview.url # => "https://api.openai.com/v1/chat/completions"
374
+ preview.payload # => {"model" => "gpt-4o", "messages" => [...], ...}
375
+ ```
376
+
367
377
  ## The low-level API
368
378
 
369
379
  Agents compile down to this API; use it directly when you need full control.
@@ -451,6 +461,13 @@ PatientLLM.ask(session,
451
461
 
452
462
  `headers` and `params` are merged on top of the provider's configured values, while the other options replace the provider defaults. All overrides are preserved across automatic tool-loop iterations.
453
463
 
464
+ `PatientLLM.preview_request` accepts the same arguments (minus `callback:`) and returns the request that `ask` would send — a `PatientLLM::RequestPreview` with the resolved `url`, `headers` (secret values redacted), and JSON `payload` — without enqueuing or executing anything:
465
+
466
+ ```ruby
467
+ preview = PatientLLM.preview_request(session, provider: :openai)
468
+ preview.payload # => {"model" => "gpt-4o", "messages" => [...]}
469
+ ```
470
+
454
471
  ### Tool calling with the registry
455
472
 
456
473
  Tools can also be registered on the global `PromptBuilder.tool_registry` with their handler, then attached to sessions by name — no schema duplication:
data/VERSION CHANGED
@@ -1 +1 @@
1
- 0.5.1
1
+ 0.6.0
@@ -353,6 +353,37 @@ module PatientLLM
353
353
  capture[:response] || raise("No response was captured; the request did not complete")
354
354
  end
355
355
 
356
+ # Build the request that {ask} would send without sending it. The
357
+ # session is built from the agent's declarations exactly as {ask} builds
358
+ # it (including any per-request session options and the user message,
359
+ # which is appended to a passed session), and the request is resolved
360
+ # through the same logic as a dispatched request. Nothing is enqueued or
361
+ # executed. See {PatientLLM.preview_request} for details on what the
362
+ # preview does and does not reflect.
363
+ #
364
+ # @param message [String, Array, Hash, nil] the user message to add
365
+ # @param session [PromptBuilder::Session, nil] an existing session to use
366
+ # instead of building a new one
367
+ # @param context [Hash, nil] accepted for parity with {ask} and ignored;
368
+ # the context does not affect the request
369
+ # @param callback [Class, String, nil] accepted for parity with {ask} and
370
+ # ignored; the callback does not affect the request
371
+ # @param options [Hash] the same per-request overrides accepted by {ask}
372
+ # @return [PatientLLM::RequestPreview] the url, headers, and JSON payload
373
+ def preview_request(message = nil, session: nil, context: nil, callback: nil, **options)
374
+ session_options = options.slice(*PromptBuilder::Session::INITIALIZE_OPTIONS)
375
+ raise ArgumentError.new("session options cannot be passed when a session is provided") if session && session_options.any?
376
+
377
+ session ||= build_session(**session_options)
378
+ session.user(message) if message
379
+
380
+ PatientLLM.preview_request(
381
+ session,
382
+ provider: provider_name!,
383
+ **options.except(*PromptBuilder::Session::INITIALIZE_OPTIONS)
384
+ )
385
+ end
386
+
356
387
  # Build a new session from the agent's declarations. Options passed by
357
388
  # the caller take precedence over the agent's declarations for the same
358
389
  # fields (pass an explicit nil to unset a declared value for one request).
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PatientLLM
4
+ # The request that {PatientLLM.ask} would send, as returned by
5
+ # {PatientLLM.preview_request} and {Agent.preview_request}. Header values
6
+ # that reference registered secrets are replaced with placeholder strings.
7
+ #
8
+ # @!attribute [r] url
9
+ # @return [String] The fully resolved request URL
10
+ # @!attribute [r] headers
11
+ # @return [Hash] The request headers, with secret values redacted
12
+ # @!attribute [r] payload
13
+ # @return [Hash] The JSON request payload
14
+ RequestPreview = Data.define(:url, :headers, :payload)
15
+ end
data/lib/patient_llm.rb CHANGED
@@ -16,6 +16,7 @@ module PatientLLM
16
16
  autoload :HaltError, File.expand_path("patient_llm/halt_error", __dir__)
17
17
  autoload :MaxToolIterationsError, File.expand_path("patient_llm/max_tool_iterations_error", __dir__)
18
18
  autoload :Presets, File.expand_path("patient_llm/presets", __dir__)
19
+ autoload :RequestPreview, File.expand_path("patient_llm/request_preview", __dir__)
19
20
  autoload :Schema, File.expand_path("patient_llm/schema", __dir__)
20
21
  autoload :StructuredOutputError, File.expand_path("patient_llm/structured_output_error", __dir__)
21
22
 
@@ -157,24 +158,65 @@ module PatientLLM
157
158
  # {Callback::MAX_TOOL_ITERATIONS}.
158
159
  # @return [Object] Handler-specific identifier for the enqueued request
159
160
  def ask(session, provider:, callback:, callback_args: {}, url: nil, serializer: nil, path: nil, headers: nil, params: nil, preprocessors: nil, timeout: nil, max_tool_iterations: nil)
160
- request_options = {}
161
- request_options["url"] = url if url
162
- request_options["serializer"] = serializer.to_s if serializer
163
- request_options["path"] = path if path
164
- request_options["headers"] = headers if headers && !headers.empty?
165
- request_options["params"] = params if params && !params.empty?
166
- request_options["preprocessors"] = preprocessors if preprocessors
167
- request_options["timeout"] = timeout if timeout
168
- request_options["max_tool_iterations"] = max_tool_iterations if max_tool_iterations
169
-
170
- # The request options travel through the job queue in the callback args,
171
- # which only permit JSON-native values; convert Symbols (e.g. serializer
172
- # names, preprocessor names, header/param values) to Strings up front.
173
- request_options = PromptBuilder.jsonify(request_options)
161
+ request_options = build_request_options(
162
+ url: url,
163
+ serializer: serializer,
164
+ path: path,
165
+ headers: headers,
166
+ params: params,
167
+ preprocessors: preprocessors,
168
+ timeout: timeout,
169
+ max_tool_iterations: max_tool_iterations
170
+ )
174
171
 
175
172
  dispatch(session, provider: provider, callback: callback, callback_args: callback_args, request_options: request_options)
176
173
  end
177
174
 
175
+ # Build the request that {.ask} would send without sending it. The same
176
+ # resolution logic as {.ask} is applied: per-request overrides are merged
177
+ # over the provider configuration, the session is serialized with the
178
+ # resolved serializer, and provider params are merged into the payload.
179
+ #
180
+ # Nothing is enqueued or executed and no callback is required. Request
181
+ # preprocessors (e.g. AWS SigV4 signing) run at send time in the request
182
+ # processor, so their changes are not reflected in the preview. Header
183
+ # values that reference registered secrets are replaced with placeholders
184
+ # and never resolved.
185
+ #
186
+ # @param session [PromptBuilder::Session] The prompt session containing conversation state
187
+ # @param provider [Symbol, String] Registered provider name
188
+ # @param url [String, nil] Override the provider's base URL for this request
189
+ # @param serializer [Symbol, nil] Override the provider's serializer for this request
190
+ # @param path [String, nil] Override the endpoint path for this request
191
+ # @param headers [Hash, nil] Additional headers merged on top of provider headers
192
+ # @param params [Hash, nil] Additional params merged into the request payload
193
+ # @param preprocessors [String, Symbol, Array<String, Symbol>, nil] Accepted for
194
+ # parity with {.ask}; preprocessors are not applied to the preview.
195
+ # @param timeout [Numeric, nil] Accepted for parity with {.ask}; does not affect the preview.
196
+ # @param max_tool_iterations [Integer, nil] Accepted for parity with {.ask}; does not
197
+ # affect the preview.
198
+ # @return [RequestPreview] The url, headers, and JSON payload the request would send
199
+ def preview_request(session, provider:, url: nil, serializer: nil, path: nil, headers: nil, params: nil, preprocessors: nil, timeout: nil, max_tool_iterations: nil)
200
+ request_options = build_request_options(
201
+ url: url,
202
+ serializer: serializer,
203
+ path: path,
204
+ headers: headers,
205
+ params: params,
206
+ preprocessors: preprocessors,
207
+ timeout: timeout,
208
+ max_tool_iterations: max_tool_iterations
209
+ )
210
+
211
+ resolved = resolve_request(session, self.provider(provider) || {}, request_options)
212
+
213
+ RequestPreview.new(
214
+ url: resolved.url,
215
+ headers: redact_secret_headers(resolved.headers),
216
+ payload: resolved.payload
217
+ )
218
+ end
219
+
178
220
  # Internal dispatch used by {.ask} and by {Callback} to re-issue requests
179
221
  # during the automatic tool loop. Not part of the public API.
180
222
  #
@@ -187,45 +229,16 @@ module PatientLLM
187
229
  PatientLLM::Callback.validate_callback_class!(PatientHttp::ClassHelper.resolve_class_name(callback.to_s))
188
230
  end
189
231
 
190
- resolved_url = request_options["url"] || provider_config[:url]
191
- raise ArgumentError, "No API base URL configured. Set url: or register a provider with a url." unless resolved_url
192
-
193
- resolved_serializer = (request_options["serializer"] || provider_config[:serializer] || :chat_completion).to_sym
194
- validate_serializer!(resolved_serializer)
195
-
196
- resolved_path = request_options["path"] || provider_config[:path] || SERIALIZER_PATHS[resolved_serializer]
197
- if resolved_path.include?("{model}")
198
- raise ArgumentError, "The endpoint path #{resolved_path.inspect} includes a {model} placeholder but session.model is not set" if session.model.nil?
199
-
200
- # Encode the model as a single path segment; Bedrock model ids can be
201
- # ARNs containing ":" and "/" that would otherwise splice extra path
202
- # segments into the URL and break SigV4 signing.
203
- resolved_path = resolved_path.gsub("{model}", URI.encode_uri_component(session.model.to_s))
204
- end
205
-
206
- resolved_headers = (provider_config[:headers] || {}).merge(request_options["headers"] || {})
207
- if resolved_serializer == :messages && !resolved_headers.key?("anthropic-version")
208
- resolved_headers = {"anthropic-version" => ANTHROPIC_VERSION}.merge(resolved_headers)
209
- end
210
-
211
- resolved_params = (provider_config[:params] || {}).merge(request_options["params"] || {})
212
- resolved_preprocessors = request_options["preprocessors"] || provider_config[:preprocessors]
213
- resolved_timeout = request_options["timeout"] || provider_config[:timeout]
214
- resolved_max_tool_iterations = (request_options["max_tool_iterations"] || provider_config[:max_tool_iterations] || Callback::MAX_TOOL_ITERATIONS).to_i
215
-
216
- payload = session.request_payload(resolved_serializer)
217
- payload = deep_merge(payload, deep_stringify_keys(resolved_params)) unless resolved_params.empty?
218
-
219
- request_url = join_url(resolved_url, resolved_path)
232
+ resolved = resolve_request(session, provider_config, request_options)
220
233
 
221
234
  dispatch_callback_args = {
222
235
  session: session_payload(session),
223
236
  provider: provider_name,
224
- serializer: resolved_serializer.to_s,
237
+ serializer: resolved.serializer.to_s,
225
238
  callback: callback.to_s,
226
239
  custom: PromptBuilder.jsonify(callback_args || {}),
227
240
  request_options: request_options,
228
- max_tool_iterations: resolved_max_tool_iterations,
241
+ max_tool_iterations: resolved.max_tool_iterations,
229
242
  tool_iteration: tool_iteration,
230
243
  original_request_id: original_request_id
231
244
  }
@@ -233,11 +246,11 @@ module PatientLLM
233
246
  if inline?
234
247
  request = PatientHttp::Request.new(
235
248
  :post,
236
- request_url,
237
- json: payload,
238
- headers: resolved_headers,
239
- preprocessors: resolved_preprocessors,
240
- timeout: resolved_timeout
249
+ resolved.url,
250
+ json: resolved.payload,
251
+ headers: resolved.headers,
252
+ preprocessors: resolved.preprocessors,
253
+ timeout: resolved.timeout
241
254
  )
242
255
  PatientHttp.execute_inline(
243
256
  request: request,
@@ -247,11 +260,11 @@ module PatientLLM
247
260
  )
248
261
  else
249
262
  PatientHttp.post(
250
- request_url,
251
- json: payload,
252
- headers: resolved_headers,
253
- preprocessors: resolved_preprocessors,
254
- timeout: resolved_timeout,
263
+ resolved.url,
264
+ json: resolved.payload,
265
+ headers: resolved.headers,
266
+ preprocessors: resolved.preprocessors,
267
+ timeout: resolved.timeout,
255
268
  raise_error_responses: true,
256
269
  callback: PatientLLM::Callback,
257
270
  callback_args: dispatch_callback_args
@@ -261,6 +274,79 @@ module PatientLLM
261
274
 
262
275
  private
263
276
 
277
+ # Fully resolved request produced by merging per-request options over the
278
+ # provider configuration.
279
+ ResolvedRequest = Data.define(:url, :serializer, :headers, :payload, :preprocessors, :timeout, :max_tool_iterations)
280
+ private_constant :ResolvedRequest
281
+
282
+ # Normalize per-request overrides into a request options hash. The request
283
+ # options travel through the job queue in the callback args, which only
284
+ # permit JSON-native values; convert Symbols (e.g. serializer names,
285
+ # preprocessor names, header/param values) to Strings up front.
286
+ def build_request_options(url:, serializer:, path:, headers:, params:, preprocessors:, timeout:, max_tool_iterations:)
287
+ request_options = {}
288
+ request_options["url"] = url if url
289
+ request_options["serializer"] = serializer.to_s if serializer
290
+ request_options["path"] = path if path
291
+ request_options["headers"] = headers if headers && !headers.empty?
292
+ request_options["params"] = params if params && !params.empty?
293
+ request_options["preprocessors"] = preprocessors if preprocessors
294
+ request_options["timeout"] = timeout if timeout
295
+ request_options["max_tool_iterations"] = max_tool_iterations if max_tool_iterations
296
+
297
+ PromptBuilder.jsonify(request_options)
298
+ end
299
+
300
+ # Resolve the request URL, headers, payload, and execution settings from
301
+ # the request options merged over the provider configuration. This is the
302
+ # single source of truth for what a request looks like; both {.dispatch}
303
+ # and {.preview_request} build requests through it.
304
+ def resolve_request(session, provider_config, request_options)
305
+ resolved_url = request_options["url"] || provider_config[:url]
306
+ raise ArgumentError, "No API base URL configured. Set url: or register a provider with a url." unless resolved_url
307
+
308
+ resolved_serializer = (request_options["serializer"] || provider_config[:serializer] || :chat_completion).to_sym
309
+ validate_serializer!(resolved_serializer)
310
+
311
+ resolved_path = request_options["path"] || provider_config[:path] || SERIALIZER_PATHS[resolved_serializer]
312
+ if resolved_path.include?("{model}")
313
+ raise ArgumentError, "The endpoint path #{resolved_path.inspect} includes a {model} placeholder but session.model is not set" if session.model.nil?
314
+
315
+ # Encode the model as a single path segment; Bedrock model ids can be
316
+ # ARNs containing ":" and "/" that would otherwise splice extra path
317
+ # segments into the URL and break SigV4 signing.
318
+ resolved_path = resolved_path.gsub("{model}", URI.encode_uri_component(session.model.to_s))
319
+ end
320
+
321
+ resolved_headers = (provider_config[:headers] || {}).merge(request_options["headers"] || {})
322
+ if resolved_serializer == :messages && !resolved_headers.key?("anthropic-version")
323
+ resolved_headers = {"anthropic-version" => ANTHROPIC_VERSION}.merge(resolved_headers)
324
+ end
325
+
326
+ resolved_params = (provider_config[:params] || {}).merge(request_options["params"] || {})
327
+
328
+ payload = session.request_payload(resolved_serializer)
329
+ payload = deep_merge(payload, deep_stringify_keys(resolved_params)) unless resolved_params.empty?
330
+
331
+ ResolvedRequest.new(
332
+ url: join_url(resolved_url, resolved_path),
333
+ serializer: resolved_serializer,
334
+ headers: resolved_headers,
335
+ payload: payload,
336
+ preprocessors: request_options["preprocessors"] || provider_config[:preprocessors],
337
+ timeout: request_options["timeout"] || provider_config[:timeout],
338
+ max_tool_iterations: (request_options["max_tool_iterations"] || provider_config[:max_tool_iterations] || Callback::MAX_TOOL_ITERATIONS).to_i
339
+ )
340
+ end
341
+
342
+ # Replace secret reference header values with placeholders so a preview
343
+ # never resolves or exposes secret values.
344
+ def redact_secret_headers(headers)
345
+ headers.transform_values do |value|
346
+ value.is_a?(PatientHttp::SecretReference) ? "<secret:#{value.name}>" : value
347
+ end
348
+ end
349
+
264
350
  # Serialize the session for the callback args, offloading it to a payload
265
351
  # store when session offloading is configured and the serialized session
266
352
  # exceeds the threshold. Offloaded payloads are not deleted after use so
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: patient_llm
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.1
4
+ version: 0.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Brian Durand
@@ -57,6 +57,7 @@ files:
57
57
  - lib/patient_llm/halt_error.rb
58
58
  - lib/patient_llm/max_tool_iterations_error.rb
59
59
  - lib/patient_llm/presets.rb
60
+ - lib/patient_llm/request_preview.rb
60
61
  - lib/patient_llm/schema.rb
61
62
  - lib/patient_llm/structured_output_error.rb
62
63
  - patient_llm.gemspec