prompt_builder 0.1.1 → 0.2.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: 2572a48b69686a8b061f84fd174dfc164adbfe0c613894bfcbb26fd0039d5108
4
- data.tar.gz: c5382987c1d85d31b6cef3f6bb90cc1e4491d1e27d1dd2587d08f2d099360c4f
3
+ metadata.gz: 9eee723a908587c28aa7e606b0e6dad45e5958a9d895d29bd42f8d984153d6bc
4
+ data.tar.gz: ffbfa13cc4bdfacc09ace7fe99a46366847faa501db6710cd51cca7123c106b2
5
5
  SHA512:
6
- metadata.gz: 75ac02437e15581cf45c13c592605b6434c0634141bda6d86324e87bb65c59502e1de00f7459c49fb6b3f432a8a2ec2c5487eab4f73de1609c26625bdb37c510
7
- data.tar.gz: 3c39ea80cbdb488a63926357601ce47b97cd5940c8ee95742b31b4df30e4b4519cf98dec931e11929ddb80569a0a469736a37aa6337af44dc53092f433051714
6
+ metadata.gz: 40023bf88305a391c88b8a189e2810e27ad5af96b2e5bbf3697296eb9b4f63a360239eed8022c343198ddeff09573874988a33372b40b7c15080603a7588f093
7
+ data.tar.gz: b9be150ab138503f29f7769f71d1ad8092cef42aa5ba04ecb4d156f070fa5dc9aecdc1995a31fabfa74ab93206d47c1a24428417e2055d999c4473669c335f59
data/CHANGELOG.md CHANGED
@@ -4,6 +4,40 @@ 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.2.0
8
+
9
+ ### Added
10
+
11
+ - `Session#use_tools` copies tool definitions from a `ToolRegistry` (the global registry by default) onto the session by name, raising a `ToolNotFoundError` for unknown names.
12
+ - `Session#json_output` configures JSON Schema structured output without writing the raw `text.format` wire hash by hand.
13
+ - `Session#think` configures reasoning portably across serializers via `effort:` or `budget_tokens:`; `think(false)` clears the configuration.
14
+ - `Response#parsed_json` parses the response text as JSON (stripping fenced ```json wrappers), returning `nil` when it cannot be parsed; `Response#parsed_json!` raises a `ParseError` including the raw text instead.
15
+ - `Response.from_text` synthesizes a completed assistant text response for canned answers, cached responses, and tests.
16
+ - `PromptBuilder::ParseError` error class.
17
+
18
+ ### Changed
19
+
20
+ - `Session.new` raises an `ArgumentError` when passed an unsupported keyword option instead of silently ignoring it.
21
+ - The Messages serializer raises an `UnsupportedFormatError` when `session.max_output_tokens` is not set, since the Messages API requires the `max_tokens` parameter.
22
+ - Serializers now raise an `UnsupportedFormatError` for missing fields their target API requires instead of emitting an invalid payload: Chat Completions, Converse, and Open Responses require `session.model`; Chat Completions requires at least one message; Gemini requires a non-empty `contents` array.
23
+ - The Messages serializer raises an `UnsupportedFormatError` when thinking is enabled with a `budget_tokens` that is not less than `max_output_tokens`, matching the Anthropic API requirement that `max_tokens` be greater than the thinking budget.
24
+ - The Converse serializer warns once per process when `session.reasoning` is set instead of silently dropping it (the Converse endpoint has no reasoning parameter).
25
+
26
+ ## 0.1.2
27
+
28
+ ### Fixed
29
+
30
+ - `Session#add_function_call_output` raised a `NameError`; it now correctly builds an `Items::FunctionCallOutput`.
31
+ - Provider-specific `extra` data on items, content blocks, and tool definitions (e.g. `cache_control`, `cache_point`, `thought_signature`, `media_type`) leaked into Open Responses request payloads and would be rejected as unknown parameters. It is now stripped from `request_payload(:open_responses)`.
32
+ - The Messages serializer emitted all `OutputText.annotations` as text-block `citations`, including annotation shapes from other providers (e.g. Chat Completions `url_citation`) that the Anthropic API rejects. Only valid Anthropic citation types are now emitted.
33
+ - The Chat Completions serializer emitted tool messages with an empty content array when a tool result contained no text content; it now collapses to an empty string.
34
+ - The Chat Completions response parser duplicated message-level `annotations` and `logprobs` onto every text block; they are now attached only to the first.
35
+ - `PromptBuilder.parse_data_url` now accepts data URLs with media type parameters (e.g. `data:text/plain;charset=utf-8;base64,...`).
36
+
37
+ ### Added
38
+
39
+ - `Response#provider_data` as an alias for `Response#extra`.
40
+
7
41
  ## 0.1.1
8
42
 
9
43
  ### Changed
data/README.md CHANGED
@@ -57,6 +57,8 @@ session = PromptBuilder::Session.new(
57
57
  )
58
58
  ```
59
59
 
60
+ Passing an option the constructor doesn't recognize (or both halves of an alias pair) raises an `ArgumentError`.
61
+
60
62
  ### Conversation History
61
63
 
62
64
  Build up a multi-turn conversation by adding messages:
@@ -78,11 +80,11 @@ Once you've built a session, serialize it to a request payload for the API you w
78
80
  **Open Responses API**:
79
81
 
80
82
  ```ruby
81
- payload = session.to_h
82
- # or
83
83
  payload = session.request_payload(:open_responses)
84
84
  ```
85
85
 
86
+ Note that `session.to_h` is *not* a request payload — it is the persistence format used by `Session.from_h` and keeps provider-specific `extra` data, canonical `url` content keys, and non-replayable items that the Open Responses API would reject. Always build request payloads through `request_payload`.
87
+
86
88
  **OpenAI Chat Completions API**:
87
89
 
88
90
  ```ruby
@@ -117,7 +119,7 @@ uri = URI("https://api.openai.com/v1/responses")
117
119
  request = Net::HTTP::Post.new(uri)
118
120
  request["Authorization"] = "Bearer #{api_key}"
119
121
  request["Content-Type"] = "application/json"
120
- request.body = JSON.generate(session.to_h)
122
+ request.body = JSON.generate(session.request_payload(:open_responses))
121
123
 
122
124
  response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) do |http|
123
125
  http.request(request)
@@ -160,6 +162,52 @@ response.has_tool_calls? # => false
160
162
  response.usage # => #<PromptBuilder::Usage input_tokens=25 output_tokens=12 ...>
161
163
  ```
162
164
 
165
+ You can also synthesize a plain-text response without calling an API — useful for canned answers (e.g. halting an agent loop), cached responses, and tests:
166
+
167
+ ```ruby
168
+ response = PromptBuilder::Response.from_text("Authentication failed.",
169
+ model: llm_response.model, usage: llm_response.usage)
170
+ response.text # => "Authentication failed."
171
+ response.completed? # => true
172
+ ```
173
+
174
+ ### Structured Output
175
+
176
+ Use `json_output` to request JSON Schema structured output and `parsed_json` to read it back:
177
+
178
+ ```ruby
179
+ schema = {
180
+ "type" => "object",
181
+ "properties" => {"answer" => {"type" => "string"}},
182
+ "required" => ["answer"]
183
+ }
184
+
185
+ session.json_output(schema, name: "response", strict: true)
186
+ payload = session.request_payload(:messages) # works with all five serializers
187
+
188
+ # After parsing the API response:
189
+ data = response.parsed_json # => {"answer" => "..."} or nil if the text isn't valid JSON
190
+ data = response.parsed_json! # raises PromptBuilder::ParseError (including the raw text) instead
191
+ ```
192
+
193
+ `parsed_json` strips fenced ```` ```json ```` wrappers, a common provider quirk. `json_output` is sugar for setting `session.text` to the canonical `format` wire hash, so direct `session.text = {...}` assignment keeps working.
194
+
195
+ ### Reasoning / Extended Thinking
196
+
197
+ Use `think` to configure reasoning portably; each serializer maps it to its native parameter:
198
+
199
+ ```ruby
200
+ session.think(effort: :medium) # portable across serializers
201
+ session.think(budget_tokens: 8_000) # explicit token budget where supported
202
+ session.think(false) # clear the reasoning configuration
203
+ ```
204
+
205
+ - `effort:` (one of `minimal`, `low`, `medium`, `high`, `xhigh`, `max`) maps to `output_config.effort` (Messages), `reasoning_effort` (Chat Completions), `thinkingConfig.thinkingLevel` (Gemini), and `reasoning.effort` (Open Responses). Levels a target API doesn't accept are omitted.
206
+ - `budget_tokens:` maps to `thinking.budget_tokens` (Messages) and `thinkingConfig.thinkingBudget` (Gemini); effort-based APIs ignore it. The Messages serializer raises an `UnsupportedFormatError` at `request_payload` time when `max_output_tokens` is not greater than the budget, since the Anthropic API requires `max_tokens > budget_tokens`.
207
+ - The Converse API has no reasoning parameter; the serializer warns once per process and omits the configuration.
208
+
209
+ Direct `session.reasoning = {...}` assignment keeps working for provider-specific keys (e.g. Anthropic's `display`, Gemini's `summary`).
210
+
163
211
  ### Agentic Tool Loops
164
212
 
165
213
  You can register tool definitions on a session, add API responses to the conversation, and manually append tool outputs to build an agentic loop:
@@ -240,6 +288,16 @@ end
240
288
  session.register_tools(PromptBuilder.tool_registry)
241
289
  ```
242
290
 
291
+ Use `use_tools` to attach a subset of registry tools by name without copying schemas by hand. Unknown names raise a `ToolNotFoundError`, so a typo fails fast instead of producing a silently absent tool:
292
+
293
+ ```ruby
294
+ session.use_tools("weather", "traffic_conditions") # from PromptBuilder.tool_registry
295
+ session.use_tools # all tools in the registry
296
+ session.use_tools(:weather, registry: my_registry) # explicit registry
297
+ ```
298
+
299
+ Tool definitions are *copied* onto the session in all cases, so later registry changes don't affect the session and the tools survive `to_h`/`from_h` round-trips.
300
+
243
301
  ### Content Types
244
302
 
245
303
  Message content can be a plain string or an array of structured content objects for multi-modal input. Content can be provided as raw Hashes or as `PromptBuilder::Content` objects.
@@ -481,6 +539,9 @@ restored_session = PromptBuilder::Session.from_h(hash)
481
539
 
482
540
  This makes it straightforward to persist conversation state in a database or cache between requests.
483
541
 
542
+ > [!WARNING]
543
+ > `to_h` is the persistence format, not a request payload. It retains provider-specific `extra` data and non-replayable items that APIs reject. Send the output of `request_payload(serializer)` to APIs, and use `to_h`/`from_h` only for storage.
544
+
484
545
  ## Serializer Compatibility
485
546
 
486
547
  The Open Responses format is the canonical data model for this gem. When serializing to other formats, some features may not be available because either the target API does not support them or because the Open Responses format does not expose parameters unique to the target API. If you attempt to use a feature that is not supported by a particular serializer, it will be silently omitted from the serialized output.
@@ -535,7 +596,7 @@ The following table shows which session configuration fields are supported by ea
535
596
  ⁹ `RefusalContent` blocks are dropped silently by all request serializers so a parsed refusal can sit in session history without breaking subsequent `request_payload` calls. A message left empty after stripping is omitted entirely.
536
597
  ¹⁰ Chat Completions sends `InputFile` as a `{"type": "file", "file": {...}}` content block. `file_id` (Files API) and base64 data URL (with optional `filename`/`media_type`) are supported. A URL-only `InputFile` (non-data URL) is omitted because Chat Completions has no remote-URL form for files.
537
598
  ¹¹ `OutputText.annotations` (e.g. URL citations from `web_search_options`) are parsed onto the assistant message and round-trip through session history, but are dropped silently on Chat Completions and Converse request serialization since those formats have no request-side equivalent. `OutputText.logprobs` is likewise dropped.
538
- ¹² Messages text-block `citations` are parsed into `OutputText.annotations` and emitted back as `citations` when serializing Messages history. Document/tool-result citation opt-ins are still not modeled.
599
+ ¹² Messages text-block `citations` are parsed into `OutputText.annotations` and emitted back as `citations` when serializing Messages history. Annotations from other providers (e.g. a Chat Completions `url_citation`) are silently dropped since they are not valid Anthropic citation shapes. Document/tool-result citation opt-ins are still not modeled.
539
600
  ¹³ Converse `requestMetadata` only accepts string key/value pairs. This serializer stringifies scalar metadata values and silently omits nested arrays or objects.
540
601
 
541
602
  ### Features Not Accessible Through Open Responses
@@ -601,6 +662,8 @@ For Messages specifically:
601
662
 
602
663
  ### Chat Completions-specific notes
603
664
 
665
+ Serializing a session without `model` set, or one that produces no messages at all, raises a `PromptBuilder::UnsupportedFormatError`.
666
+
604
667
  Request-side mappings worth calling out:
605
668
 
606
669
  | Canonical field / value | Chat Completions mapping |
@@ -617,7 +680,7 @@ Request-side mappings worth calling out:
617
680
  | `InputFile` with data URL (+ optional `filename`/`media_type`) | `{"type": "file", "file": {"filename": ..., "file_data": "data:<media_type>;base64,..."}}` |
618
681
  | `InputImage` with data URL (+ `media_type`) | `{"type": "image_url", "image_url": {"url": "data:<media_type>;base64,..."}}` |
619
682
 
620
- Unsupported Chat Completions request features not exposed by this gem include audio input/output (`audio`, `modalities`, `input_audio` content), `web_search_options`, custom tools, `tool_choice: {"type": "allowed_tools", ...}`, `seed`, `stop`, `logit_bias`, `n`, `prediction`, and the deprecated `functions`, `function_call`, `max_tokens`, and `user` fields. Use the modern canonical fields when available (`tools`, `tool_choice`, `max_output_tokens`, `safety_identifier`, `prompt_cache_key`).
683
+ Chat Completions request features with no canonical Open Responses field are available through `session.extra` (`stop`, `seed`, `logit_bias`, `n`, `prediction`, `web_search_options`, `modalities`, `audio`). Features not exposed at all include `input_audio` content blocks, custom tools, `tool_choice: {"type": "allowed_tools", ...}`, and the deprecated `functions`, `function_call`, `max_tokens`, and `user` fields. Use the modern canonical fields when available (`tools`, `tool_choice`, `max_output_tokens`, `safety_identifier`, `prompt_cache_key`).
621
684
 
622
685
  Response-side behavior and limitations:
623
686
 
@@ -631,6 +694,8 @@ Response-side behavior and limitations:
631
694
 
632
695
  ### Anthropic Messages-specific mappings
633
696
 
697
+ The Messages API requires the `max_tokens` parameter, which is populated from `session.max_output_tokens`. Serializing a session without `max_output_tokens` set raises a `PromptBuilder::UnsupportedFormatError`.
698
+
634
699
  A few features map between the canonical Open Responses format and the Messages API in non-obvious ways:
635
700
 
636
701
  | Canonical field / value | Messages mapping |
@@ -654,6 +719,8 @@ Built-in tool response content blocks (`server_tool_use`, `web_search_tool_resul
654
719
 
655
720
  ### Gemini-specific notes
656
721
 
722
+ Serializing a session without `model` set (the model belongs in the request URL, but it is validated at serialization time), or one that produces an empty `contents` array, raises a `PromptBuilder::UnsupportedFormatError`.
723
+
657
724
  Request-side mappings worth calling out:
658
725
 
659
726
  | Canonical field / value | Gemini mapping |
@@ -676,7 +743,7 @@ Content and message restrictions:
676
743
  - Assistant messages map to `role: "model"`; consecutive same-role turns are merged automatically.
677
744
  - `InputImage`, `InputFile`, and `InputVideo` are only supported in user messages; the same content on an assistant message is omitted.
678
745
  - `InputImage` accepts any URL, a base64 data URL (with required `media_type`), or a Files API `file_id`.
679
- - `InputFile` accepts any URL, a base64 data URL, or `file_id`. `media_type` is required when not inferable from a `filename` or URL extension. Recognized extensions: `pdf`, `txt`, `md`/`markdown`, `html`/`htm`, `csv`, `json`, `xml`, `rtf`.
746
+ - `InputFile` accepts any URL, a base64 data URL, or `file_id`. `media_type` is required when not inferable from a `filename` or URL extension. Recognized extensions cover documents (`pdf`, `txt`, `md`/`markdown`, `html`/`htm`, `csv`, `json`, `xml`, `rtf`), images (`png`, `jpg`/`jpeg`, `webp`, `heic`, `heif`), audio (`mp3`, `wav`, `aiff`, `aac`, `ogg`, `flac`), and video (`mp4`, `mov`, `webm`, `mpeg`/`mpg`).
680
747
  - `InputVideo` requires a URL; raw bytes are not modeled.
681
748
  - `RefusalContent` is dropped silently so a parsed Chat Completions refusal can sit in session history without breaking subsequent serialization.
682
749
  - `Reasoning` items round-trip via `parts[].thought` with `thoughtSignature` preserved. `redacted_thinking`, `summary`, and unknown reasoning block types are silently skipped.
@@ -689,7 +756,7 @@ Response-side limitations:
689
756
 
690
757
  - Unknown response `Part` shapes (`inlineData`, `fileData`, `executableCode`, `codeExecutionResult`, `videoMetadata`, server-side `toolCall`/`toolResponse`, or any `Part` without a recognized content key) are silently skipped.
691
758
  - Only `candidates[0]` is parsed. When `candidateCount > 1`, additional candidates are dropped (their index is preserved on `provider_data`).
692
- - Function-call `id` from the response is dropped; the parser synthesizes `gemini_call_<seed>_<n>` so multiple calls in one response share a deterministic seed.
759
+ - Function-call `id` from the response is preserved when present; otherwise the parser synthesizes `gemini_call_<seed>_<n>` so multiple calls in one response share a deterministic seed.
693
760
  - `finishReason` mappings: `STOP` → `completed`; `MAX_TOKENS` → `incomplete`; `SAFETY`, `RECITATION`, `OTHER`, `BLOCKLIST`, `PROHIBITED_CONTENT`, `SPII`, `MALFORMED_FUNCTION_CALL`, `IMAGE_SAFETY`, `LANGUAGE`, `UNEXPECTED_TOOL_CALL`, `TOO_MANY_TOOL_CALLS`, `MODEL_ARMOR` → `failed`. `FINISH_REASON_UNSPECIFIED` is treated as nil.
694
761
  - An empty `candidates` array combined with `promptFeedback.blockReason` is mapped to `failed`.
695
762
  - `usageMetadata.cachedContentTokenCount`, `toolUsePromptTokenCount`, `promptTokensDetails`, `cacheTokensDetails`, and `toolUsePromptTokensDetails` populate `response.usage.input_tokens_details`. `thoughtsTokenCount` and `candidatesTokensDetails` populate `response.usage.output_tokens_details`.
@@ -697,6 +764,8 @@ Response-side limitations:
697
764
 
698
765
  ### Converse-specific notes
699
766
 
767
+ Serializing a session without `model` set (populates the required `modelId` parameter) raises a `PromptBuilder::UnsupportedFormatError`.
768
+
700
769
  Request-side restrictions worth calling out:
701
770
 
702
771
  | Canonical field / value | Converse mapping |
@@ -725,14 +794,15 @@ Content and message restrictions:
725
794
  - `FunctionCall.arguments` must parse to a JSON object; non-object JSON values raise (Bedrock's `toolUse.input` requires an object).
726
795
  - `FunctionCallOutput.output` content supports `InputText`/`OutputText`, `InputImage`, `InputFile`, and `InputVideo`, mapping to Converse `text`, `image`, `document`, and `video` tool result blocks. Converse also supports `json` and `searchResult` tool result blocks, but this gem has no canonical content types for them, so unsupported content is omitted. `FunctionCallOutput.status` is mapped: `completed` → `success`, `failed`/`incomplete` → `error`, anything else passes through or is dropped.
727
796
  - `tool_choice: "none"` and `tool_choice` without registered tools are both omitted.
728
- - Converse API request features not exposed by this gem include `stopSequences`, `additionalModelRequestFields`, `additionalModelResponseFieldPaths`, `guardrailConfig` / `guardContent`, `cachePoint`, Prompt Management `promptVariables`, audio blocks, `searchResult` blocks, and `performanceConfig.latency`.
797
+ - Converse API request features with no canonical Open Responses field are available through `session.extra` (`stop_sequences`, `guardrail_config`, `additional_model_request_fields`, `additional_model_response_field_paths`, `performance_config`, `prompt_variables`) and content `extra` (`cache_point`). Features not exposed at all include `guardContent`, audio blocks, and `searchResult` blocks.
798
+ - The serialized payload includes `modelId` as a convenience; the Converse REST endpoint takes the model id in the URL path (`/model/{modelId}/converse`), and AWS REST-JSON services ignore unrecognized body members. Remove it from the body if you prefer a strictly minimal payload.
729
799
 
730
800
  Response-side limitations:
731
801
 
732
802
  - Unknown content block keys (e.g. `citationsContent`, `guardContent`) are silently skipped.
733
803
  - `metrics.latencyMs`, `trace` (guardrail and prompt-router trace events), `additionalModelResponseFields`, `performanceConfig`, and the raw `serviceTier` object are exposed on `response.provider_data`. `response.service_tier` is also populated from `serviceTier.type`.
734
- - `stopReason` mappings: `end_turn` / `tool_use` / `stop_sequence` → `completed`, `max_tokens` / `model_context_window_exceeded` → `incomplete`, `guardrail_intervened` / `content_filtered` / `malformed_model_output` / `malformed_tool_use` → `failed`. Unlike the Messages serializer, the matched stop sequence text is not surfaced separately because Converse does not echo it back unless you request provider-specific fields through `additionalModelResponseFieldPaths`, which this gem cannot emit.
735
- - `usage.cacheReadInputTokens` and `usage.cacheWriteInputTokens` populate `response.usage.input_tokens_details["cached_tokens"]` and `["cache_creation_input_tokens"]`. Cache writes still require `cachePoint` markers in the request, which this gem cannot produce.
804
+ - `stopReason` mappings: `end_turn` / `tool_use` / `stop_sequence` → `completed`, `max_tokens` / `model_context_window_exceeded` → `incomplete`, `guardrail_intervened` / `content_filtered` / `malformed_model_output` / `malformed_tool_use` → `failed`. Unlike the Messages serializer, the matched stop sequence text is not surfaced separately because Converse does not echo it back unless you request provider-specific fields through `additionalModelResponseFieldPaths` (available via the `additional_model_response_field_paths` session extra; the requested fields appear on `response.provider_data`).
805
+ - `usage.cacheReadInputTokens` and `usage.cacheWriteInputTokens` populate `response.usage.input_tokens_details["cached_tokens"]` and `["cache_creation_input_tokens"]`. Cache writes require `cachePoint` markers in the request, which are emitted via the `cache_point` content extra.
736
806
 
737
807
  ## Installation
738
808
 
data/VERSION CHANGED
@@ -1 +1 @@
1
- 0.1.1
1
+ 0.2.0
@@ -18,4 +18,7 @@ module PromptBuilder
18
18
 
19
19
  # Raised when a response payload does not match the expected shape.
20
20
  class UnexpectedPayloadError < Error; end
21
+
22
+ # Raised by Response#parsed_json! when the response text is not valid JSON.
23
+ class ParseError < Error; end
21
24
  end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "json"
4
+
3
5
  module PromptBuilder
4
6
  # Represents a parsed API response from the Open Responses API.
5
7
  # All fields are optional and will be nil if not present in the response.
@@ -125,6 +127,14 @@ module PromptBuilder
125
127
  FIELDS.each { |f| attr_reader f }
126
128
  attr_reader :text_config, :output, :usage, :extra
127
129
 
130
+ # @!method provider_data
131
+ # Provider-specific response metadata with no canonical Open Responses
132
+ # slot (e.g. Gemini grounding metadata, Chat Completions system_fingerprint).
133
+ # Alias for +extra+.
134
+ #
135
+ # @return [Hash, nil]
136
+ alias_method :provider_data, :extra
137
+
128
138
  BOOLEAN_FIELDS.each { |f| alias_method("#{f}?", f) }
129
139
 
130
140
  # Create a new Response.
@@ -165,6 +175,23 @@ module PromptBuilder
165
175
  attrs = FIELDS.each_with_object({}) { |f, acc| acc[f] = hash[f.to_s] }
166
176
  new(**attrs, text_config: hash["text"], output: output, usage: usage, extra: hash["extra"])
167
177
  end
178
+
179
+ # Synthesize a Response containing a single assistant text message.
180
+ # Useful for canned answers (halt messages, cached responses) and tests
181
+ # without assembling Items::Message and Content::OutputText by hand.
182
+ #
183
+ # @param text [String] the assistant message text
184
+ # @param status [String] the response status
185
+ # @param attributes [Hash] any other Response attributes (e.g. +model+,
186
+ # +usage+, +id+)
187
+ # @return [Response]
188
+ # @example
189
+ # PromptBuilder::Response.from_text("Authentication failed.",
190
+ # model: llm_response.model, usage: llm_response.usage)
191
+ def from_text(text, status: "completed", **attributes)
192
+ message = Items::Message.new(role: "assistant", content: [Content::OutputText.new(text: text)])
193
+ new(status: status, output: [message], **attributes)
194
+ end
168
195
  end
169
196
 
170
197
  # Check if the response completed successfully.
@@ -216,6 +243,40 @@ module PromptBuilder
216
243
  nil
217
244
  end
218
245
 
246
+ # Parse the response text as JSON, for use with structured output
247
+ # (see +Session#json_output+). Strips a fenced ```json wrapper when
248
+ # present — a common provider quirk. Returns nil when the response has
249
+ # no text or the text is not valid JSON; use +parsed_json!+ to raise
250
+ # instead.
251
+ #
252
+ # @return [Hash, Array, Object, nil] the parsed JSON value, or nil
253
+ def parsed_json
254
+ raw = text
255
+ return nil unless raw
256
+
257
+ begin
258
+ JSON.parse(strip_json_fences(raw))
259
+ rescue JSON::ParserError
260
+ nil
261
+ end
262
+ end
263
+
264
+ # Parse the response text as JSON, raising when it cannot be parsed.
265
+ #
266
+ # @return [Hash, Array, Object] the parsed JSON value
267
+ # @raise [ParseError] if the response has no text or the text is not
268
+ # valid JSON; the error message includes the raw text
269
+ def parsed_json!
270
+ raw = text
271
+ raise ParseError, "Response has no text output to parse as JSON" unless raw
272
+
273
+ begin
274
+ JSON.parse(strip_json_fences(raw))
275
+ rescue JSON::ParserError => e
276
+ raise ParseError, "Response text is not valid JSON (#{e.message}); raw text: #{raw}"
277
+ end
278
+ end
279
+
219
280
  # Serialize to a Hash with string keys. Nil values are omitted.
220
281
  #
221
282
  # @return [Hash]
@@ -238,6 +299,12 @@ module PromptBuilder
238
299
 
239
300
  private
240
301
 
302
+ def strip_json_fences(raw)
303
+ stripped = raw.strip
304
+ match = stripped.match(/\A```(?:json)?\s*\n(.*?)\n?```\z/m)
305
+ match ? match[1] : stripped
306
+ end
307
+
241
308
  def coerce_field(field, value)
242
309
  return value if value.nil?
243
310
 
@@ -5,6 +5,11 @@ module PromptBuilder
5
5
  class ChatCompletion < Base
6
6
  # Request serializer for the OpenAI Chat Completions API format.
7
7
  #
8
+ # Required session fields (an UnsupportedFormatError is raised when missing):
9
+ # - +model+
10
+ # - at least one message (+instructions+ or a conversation item that
11
+ # serializes to a message)
12
+ #
8
13
  # === Unsupported Open Responses features
9
14
  #
10
15
  # These session fields are not supported and are silently omitted from the
@@ -62,8 +67,14 @@ module PromptBuilder
62
67
 
63
68
  def serialize_request(session)
64
69
  h = {}
65
- h["model"] = session.model if session.model
66
- h["messages"] = build_messages(session)
70
+ raise UnsupportedFormatError, "Chat Completions format requires session.model" unless session.model
71
+
72
+ h["model"] = session.model
73
+ messages = build_messages(session)
74
+ if messages.empty?
75
+ raise UnsupportedFormatError, "Chat Completions format requires at least one message"
76
+ end
77
+ h["messages"] = messages
67
78
  h["temperature"] = session.temperature if session.temperature
68
79
  h["top_p"] = session.top_p if session.top_p
69
80
  h["presence_penalty"] = session.presence_penalty if session.presence_penalty
@@ -228,12 +239,16 @@ module PromptBuilder
228
239
 
229
240
  # Only text content is supported in tool output; other content types
230
241
  # are silently omitted.
231
- output.filter_map do |content|
242
+ content = output.filter_map do |content|
232
243
  case content
233
244
  when Content::InputText, Content::OutputText
234
245
  serialize_text_content(content)
235
246
  end
236
247
  end
248
+
249
+ # Chat Completions rejects tool messages with an empty content
250
+ # array; collapse to an empty string like the other serializers.
251
+ content.empty? ? "" : content
237
252
  end
238
253
 
239
254
  def flush_tool_calls!(messages, pending_tool_calls, last_assistant_msg = nil)
@@ -36,6 +36,10 @@ module PromptBuilder
36
36
  content: [Content::RefusalContent.new(refusal: message["refusal"])]
37
37
  )
38
38
  elsif message["content"].is_a?(Array)
39
+ # logprobs and annotations are reported once per message, so
40
+ # attach them to the first text block only to avoid duplicating
41
+ # them across blocks on round-trip.
42
+ first_text_block = true
39
43
  contents = message["content"].each_with_object([]) do |block, acc|
40
44
  case block["type"]
41
45
  when "text", nil
@@ -44,9 +48,10 @@ module PromptBuilder
44
48
 
45
49
  acc << Content::OutputText.new(
46
50
  text: text,
47
- logprobs: logprobs_content,
48
- annotations: annotations
51
+ logprobs: first_text_block ? logprobs_content : [],
52
+ annotations: first_text_block ? annotations : []
49
53
  )
54
+ first_text_block = false
50
55
  when "refusal"
51
56
  acc << Content::RefusalContent.new(refusal: block["refusal"]) if block["refusal"]
52
57
  else
@@ -8,6 +8,9 @@ module PromptBuilder
8
8
  class Converse < Base
9
9
  # Request serializer for the Amazon Bedrock Converse API format.
10
10
  #
11
+ # Required session fields (an UnsupportedFormatError is raised when missing):
12
+ # - +model+ — populates the required +modelId+ parameter
13
+ #
11
14
  # === Unsupported Open Responses features
12
15
  #
13
16
  # These session fields are not supported and are silently omitted from the
@@ -19,7 +22,8 @@ module PromptBuilder
19
22
  # - +parallel_tool_calls+ — parallel tool call control is not supported
20
23
  # - +presence_penalty+ — not supported by the Converse API
21
24
  # - +prompt_cache_key+ / +prompt_cache_retention+ — explicit prompt cache keys are not supported
22
- # - +reasoning+ — extended thinking is not supported on the Converse endpoint
25
+ # - +reasoning+ — extended thinking is not supported on the Converse endpoint;
26
+ # a warning is issued (once per process) when +session.reasoning+ is set
23
27
  # - +safety_identifier+ — no equivalent user-safety field on the Converse endpoint
24
28
  # - +store+ — server-side response storage is not supported
25
29
  # - +stream+ — SSE streaming is handled outside the Converse request payload
@@ -55,15 +59,17 @@ module PromptBuilder
55
59
  # === Features in Converse not available through Open Responses
56
60
  #
57
61
  # The following Converse parameters cannot be set through the Open Responses
58
- # canonical format:
59
- # - +stopSequences+ — custom stop sequences
60
- # - Guardrail policies (+guardrailConfig+)
61
- # - Per-model passthrough fields (+additionalModelRequestFields+)
62
- # - Requested provider response fields (+additionalModelResponseFieldPaths+)
63
- # - Cross-region routing via inference profiles
64
- # - +performanceConfig+ latency settings beyond +serviceTier+
65
- # - Prompt management variables (+promptVariables+)
66
- # - Prompt caching markers (+cachePoint+)
62
+ # canonical format but are recognized via session +extra+ keys:
63
+ # - +stopSequences+ — custom stop sequences (+stop_sequences+)
64
+ # - Guardrail policies (+guardrail_config+)
65
+ # - Per-model passthrough fields (+additional_model_request_fields+)
66
+ # - Requested provider response fields (+additional_model_response_field_paths+)
67
+ # - +performanceConfig+ latency settings (+performance_config+)
68
+ # - Prompt management variables (+prompt_variables+)
69
+ #
70
+ # Prompt caching markers (+cachePoint+) are emitted via the +cache_point+
71
+ # content extra. Cross-region routing via inference profiles is selected
72
+ # through the model id and needs no request field.
67
73
  class Request < Base
68
74
  IMAGE_MEDIA_TYPE_FORMATS = {
69
75
  "image/jpeg" => "jpeg",
@@ -108,6 +114,13 @@ module PromptBuilder
108
114
  }.freeze
109
115
 
110
116
  class << self
117
+ # Reset the once-per-process reasoning warning. Primarily used in tests.
118
+ #
119
+ # @return [void]
120
+ def reset_reasoning_warning!
121
+ @reasoning_warning_issued = false
122
+ end
123
+
111
124
  private
112
125
 
113
126
  def serialize_request(session)
@@ -116,7 +129,11 @@ module PromptBuilder
116
129
  ctx = {document_name_counts: Hash.new(0)}
117
130
 
118
131
  h = {}
119
- h["modelId"] = session.model if session.model
132
+ raise UnsupportedFormatError, "Converse format requires session.model" unless session.model
133
+
134
+ warn_reasoning_unsupported! if session.reasoning
135
+
136
+ h["modelId"] = session.model
120
137
 
121
138
  system = build_system(session)
122
139
  h["system"] = system unless system.empty?
@@ -152,6 +169,16 @@ module PromptBuilder
152
169
  h
153
170
  end
154
171
 
172
+ # The Converse endpoint has no reasoning/extended thinking parameter,
173
+ # so the reasoning config is dropped. Warn once per process instead of
174
+ # dropping it silently.
175
+ def warn_reasoning_unsupported!
176
+ return if @reasoning_warning_issued
177
+
178
+ @reasoning_warning_issued = true
179
+ warn "PromptBuilder: session.reasoning is not supported by the Converse format and was omitted from the request payload"
180
+ end
181
+
155
182
  def apply_session_extra!(h, extra)
156
183
  if extra.key?("stop_sequences")
157
184
  inference_config = h["inferenceConfig"] ||= {}
@@ -7,6 +7,11 @@ module PromptBuilder
7
7
  class Gemini < Base
8
8
  # Request serializer for the Google Gemini API format.
9
9
  #
10
+ # Required session fields (an UnsupportedFormatError is raised when missing):
11
+ # - +model+ — not part of the payload (it belongs in the request URL) but
12
+ # validated so an incomplete session fails at serialization time
13
+ # - at least one user/model message (the +contents+ array must not be empty)
14
+ #
10
15
  # === Unsupported Open Responses features
11
16
  #
12
17
  # These session fields are not supported and are silently omitted from the
@@ -115,7 +120,11 @@ module PromptBuilder
115
120
  system_instruction = build_system_instruction(session)
116
121
  h["systemInstruction"] = system_instruction if system_instruction
117
122
 
118
- h["contents"] = build_contents(session)
123
+ contents = build_contents(session)
124
+ if contents.empty?
125
+ raise UnsupportedFormatError, "Gemini format requires at least one user/model message"
126
+ end
127
+ h["contents"] = contents
119
128
 
120
129
  generation_config = build_generation_config(session)
121
130
  h["generationConfig"] = generation_config unless generation_config.empty?
@@ -5,6 +5,10 @@ module PromptBuilder
5
5
  class Messages < Base
6
6
  # Request serializer for the Anthropic Messages API format.
7
7
  #
8
+ # Required session fields (an UnsupportedFormatError is raised when missing):
9
+ # - +model+
10
+ # - +max_output_tokens+ — populates the required +max_tokens+ parameter
11
+ #
8
12
  # === Unsupported Open Responses features
9
13
  #
10
14
  # These session fields are not supported and are silently omitted from the
@@ -27,7 +31,9 @@ module PromptBuilder
27
31
  # - +service_tier+ — only +auto+ and +standard_only+ are accepted
28
32
  # - +text+ — +format.type=json_schema+ is mapped to +output_config.format+
29
33
  # - +reasoning+ — +budget_tokens+, +display+, +effort+, and +type+ are forwarded;
30
- # +temperature+ must be unset and +top_p+ must be >= 0.95 when reasoning is enabled
34
+ # +temperature+ must be unset, +top_p+ must be >= 0.95, and
35
+ # +max_output_tokens+ must be greater than +budget_tokens+ when reasoning
36
+ # is enabled
31
37
  #
32
38
  # Input content restrictions:
33
39
  # - +InputVideo+ content is not supported and is omitted
@@ -76,6 +82,17 @@ module PromptBuilder
76
82
  SUPPORTED_THINKING_TYPES = ["adaptive", "disabled", "enabled"].freeze
77
83
  SUPPORTED_TOOL_CHOICE_TYPES = ["any", "auto", "none", "tool"].freeze
78
84
 
85
+ # Citation types accepted by the Messages API on text blocks. Annotations
86
+ # parsed from other providers (e.g. Chat Completions url_citation) have
87
+ # no Anthropic equivalent and are silently dropped.
88
+ SUPPORTED_CITATION_TYPES = [
89
+ "char_location",
90
+ "content_block_location",
91
+ "page_location",
92
+ "search_result_location",
93
+ "web_search_result_location"
94
+ ].freeze
95
+
79
96
  class << self
80
97
  private
81
98
 
@@ -84,7 +101,11 @@ module PromptBuilder
84
101
  raise UnsupportedFormatError, "Messages format requires session.model" unless session.model
85
102
 
86
103
  h["model"] = session.model
87
- h["max_tokens"] = session.max_output_tokens if session.max_output_tokens
104
+ unless session.max_output_tokens
105
+ raise UnsupportedFormatError,
106
+ "Messages format requires session.max_output_tokens to populate the required max_tokens field"
107
+ end
108
+ h["max_tokens"] = session.max_output_tokens
88
109
  h["temperature"] = session.temperature if session.temperature
89
110
  h["top_p"] = session.top_p if session.top_p
90
111
  effective_metadata = build_effective_metadata(session)
@@ -238,6 +259,12 @@ module PromptBuilder
238
259
  def validate_thinking_compatibility!(session, thinking)
239
260
  return unless thinking
240
261
 
262
+ budget = thinking["budget_tokens"]
263
+ if budget && session.max_output_tokens && session.max_output_tokens <= budget
264
+ raise UnsupportedFormatError,
265
+ "Messages format requires max_output_tokens (max_tokens) to be greater than reasoning.budget_tokens"
266
+ end
267
+
241
268
  if session.temperature
242
269
  raise UnsupportedFormatError,
243
270
  "Messages format does not support temperature when thinking is enabled"
@@ -340,7 +367,8 @@ module PromptBuilder
340
367
  {"type" => "text", "text" => content.text}
341
368
  when Content::OutputText
342
369
  text = {"type" => "text", "text" => content.text}
343
- text["citations"] = content.annotations unless content.annotations.empty?
370
+ citations = serialize_citations(content.annotations)
371
+ text["citations"] = citations unless citations.empty?
344
372
  text
345
373
  when Content::InputImage
346
374
  # Assistant image content is not supported; omit it.
@@ -424,6 +452,16 @@ module PromptBuilder
424
452
  end
425
453
  end
426
454
 
455
+ # Only annotations that are valid Anthropic citation objects can be
456
+ # replayed as citations. Annotations from other providers (e.g. a
457
+ # Chat Completions url_citation) are silently dropped so a
458
+ # cross-provider session history doesn't produce an invalid request.
459
+ def serialize_citations(annotations)
460
+ annotations.select do |annotation|
461
+ annotation.is_a?(Hash) && SUPPORTED_CITATION_TYPES.include?(annotation["type"])
462
+ end
463
+ end
464
+
427
465
  # FunctionCallOutput.status values that map to tool_result.is_error.
428
466
  # "incomplete" and "failed" cover the OR canonical statuses; "error"
429
467
  # is accepted as a convenience alias.
@@ -4,17 +4,31 @@ module PromptBuilder
4
4
  module Serializers
5
5
  class OpenResponses < Base
6
6
  # Request serializer for the OpenAI Open Responses API format.
7
+ #
8
+ # Required session fields (an UnsupportedFormatError is raised when missing):
9
+ # - +model+
7
10
  class Request < Base
11
+ # Content extra keys that are part of the Open Responses API schema and
12
+ # must survive extra-stripping (the canonical content classes have no
13
+ # dedicated attribute for them).
14
+ CANONICAL_EXTRA_KEYS = {
15
+ "input_file" => ["file_id"].freeze,
16
+ "input_image" => ["file_id"].freeze
17
+ }.freeze
18
+ private_constant :CANONICAL_EXTRA_KEYS
19
+
8
20
  class << self
9
21
  # Export a session to Open Responses API request payload.
10
22
  #
11
23
  # @param session [Session] the session to export
12
24
  # @return [Hash] the serialized request payload
13
25
  def request_payload(session)
26
+ raise UnsupportedFormatError, "Open Responses format requires session.model" unless session.model
27
+
14
28
  payload = session.to_h
15
- apply_server_state!(payload, session)
29
+ items = apply_server_state!(payload, session)
16
30
  payload.delete("extra")
17
- strip_extra(payload)
31
+ strip_extra(payload, items, session.tool_definitions)
18
32
  normalize_content_urls!(payload)
19
33
  strip_non_replayable_reasoning!(payload)
20
34
  strip_output_only_fields!(payload)
@@ -28,36 +42,78 @@ module PromptBuilder
28
42
  # When the session is in server-state mode, replace the full input
29
43
  # array with only items added after the last response boundary and
30
44
  # include previous_response_id for the API to resolve history.
45
+ # Returns the item objects backing the payload's input array.
31
46
  def apply_server_state!(payload, session)
32
- return if session.local_state?
47
+ return session.items if session.local_state?
33
48
 
34
49
  payload.delete("input")
35
- new_items = session.items[session.response_boundary_index..]
36
- payload["input"] = new_items.map(&:to_h) if new_items && !new_items.empty?
50
+ new_items = session.items[session.response_boundary_index..] || []
51
+ payload["input"] = new_items.map(&:to_h) unless new_items.empty?
52
+ new_items
37
53
  end
38
54
 
39
- # Walk the payload and remove any "extra" keys from items, content
40
- # blocks, and tool definitions since they are not part of the Open
41
- # Responses API schema.
42
- def strip_extra(payload)
55
+ # Remove provider-specific extra data from items, content blocks, and
56
+ # tool definitions since it is not part of the Open Responses API
57
+ # schema. Items, content blocks, and tool definitions serialize their
58
+ # extras flat into the hash, so the extra keys must be looked up on
59
+ # the backing objects rather than removed as a nested "extra" key.
60
+ def strip_extra(payload, items, tool_definitions)
43
61
  input = payload["input"]
44
62
  if input.is_a?(Array)
45
- input.each do |item|
46
- next unless item.is_a?(Hash)
63
+ input.each_with_index do |item_hash, index|
64
+ next unless item_hash.is_a?(Hash)
65
+
66
+ item = items[index]
67
+ next unless item
47
68
 
48
- item.delete("extra")
69
+ strip_extra_keys!(item_hash, item.extra)
49
70
 
50
- if item["type"] == "function_call_output" && item["output"].is_a?(Array)
51
- strip_extra_from_blocks!(item["output"])
52
- else
53
- content = item["content"]
54
- strip_extra_from_blocks!(content) if content.is_a?(Array)
71
+ case item
72
+ when Items::Message
73
+ strip_extra_from_blocks!(item_hash["content"], item.content)
74
+ when Items::FunctionCallOutput
75
+ strip_extra_from_blocks!(item_hash["output"], item.output) if item.output.is_a?(Array)
55
76
  end
56
77
  end
57
78
  end
58
79
 
59
80
  tools = payload["tools"]
60
- strip_extra_from_blocks!(tools) if tools.is_a?(Array)
81
+ if tools.is_a?(Array)
82
+ tools.each_with_index do |tool_hash, index|
83
+ next unless tool_hash.is_a?(Hash)
84
+
85
+ definition = tool_definitions[index]
86
+ strip_extra_keys!(tool_hash, definition.extra) if definition
87
+ end
88
+ end
89
+ end
90
+
91
+ def strip_extra_from_blocks!(blocks, contents)
92
+ return unless blocks.is_a?(Array)
93
+
94
+ blocks.each_with_index do |block, index|
95
+ next unless block.is_a?(Hash)
96
+
97
+ content = contents[index]
98
+ next unless content.respond_to?(:extra)
99
+
100
+ keep = CANONICAL_EXTRA_KEYS.fetch(block["type"], [])
101
+ strip_extra_keys!(block, content.extra, keep: keep)
102
+ end
103
+ end
104
+
105
+ def strip_extra_keys!(hash, extra, keep: [])
106
+ return unless extra.is_a?(Hash)
107
+
108
+ extra.each_key do |key|
109
+ # "type" is canonical on every item and content block; an extra
110
+ # named "type" never survives serialization, so deleting it here
111
+ # would corrupt the block.
112
+ next if key == "type"
113
+ next if keep.include?(key)
114
+
115
+ hash.delete(key)
116
+ end
61
117
  end
62
118
 
63
119
  # Convert canonical "url" keys back to Open Responses API keys:
@@ -201,14 +257,6 @@ module PromptBuilder
201
257
  normalized.empty? ? nil : normalized
202
258
  end
203
259
 
204
- def strip_extra_from_blocks!(blocks)
205
- blocks.each do |block|
206
- next unless block.is_a?(Hash)
207
-
208
- block.delete("extra")
209
- end
210
- end
211
-
212
260
  # Normalize text.format for the Responses API. If the format uses
213
261
  # the Chat Completions nested json_schema sub-object, flatten it
214
262
  # so name/schema/strict/description sit directly under format.
@@ -27,6 +27,19 @@ module PromptBuilder
27
27
  JSONIFY_FIELDS = %i[include tool_choice metadata text stream_options reasoning].freeze
28
28
  private_constant :JSONIFY_FIELDS
29
29
 
30
+ # Effort levels accepted by +think+. This is the union of the levels
31
+ # recognized across serializers; each serializer omits levels its target
32
+ # API does not support.
33
+ THINK_EFFORT_LEVELS = %w[minimal low medium high xhigh max].freeze
34
+ private_constant :THINK_EFFORT_LEVELS
35
+
36
+ # All keyword options accepted by +initialize+.
37
+ INITIALIZE_OPTIONS = (
38
+ STRING_FIELDS + FLOAT_FIELDS + INTEGER_FIELDS + BOOLEAN_FIELDS + JSONIFY_FIELDS +
39
+ %i[input extra]
40
+ ).freeze
41
+ private_constant :INITIALIZE_OPTIONS
42
+
30
43
  # @!attribute [rw] model
31
44
  # @return [String, nil] the model identifier
32
45
  # @!attribute [rw] instructions
@@ -145,14 +158,22 @@ module PromptBuilder
145
158
  # Create a new Session with the given options.
146
159
  # Accepts keyword arguments for all typed field groups (STRING_FIELDS,
147
160
  # FLOAT_FIELDS, INTEGER_FIELDS, BOOLEAN_FIELDS, JSONIFY_FIELDS); all default
148
- # to +nil+. The +input+ shorthand auto-creates a user message if provided.
161
+ # to +nil+. The +input+ (or +user+) shorthand auto-creates a user message
162
+ # if provided. Unsupported keyword options raise an ArgumentError.
149
163
  #
150
164
  # @param attributes [Hash] keyword options; see attribute declarations above
151
165
  # @option attributes [String, nil] :input optional string shorthand; a user
152
166
  # message is automatically added with this text
153
167
  # @option attributes [Hash, nil] :extra provider-specific extra data for
154
168
  # serializers; recognized keys vary by target format
169
+ # @raise [ArgumentError] if an unsupported option is passed or aliased
170
+ # options are passed together
155
171
  def initialize(**attributes)
172
+ unsupported = attributes.keys - INITIALIZE_OPTIONS
173
+ unless unsupported.empty?
174
+ raise ArgumentError, "unsupported option#{"s" if unsupported.size > 1}: #{unsupported.join(", ")}"
175
+ end
176
+
156
177
  (STRING_FIELDS + FLOAT_FIELDS + INTEGER_FIELDS + BOOLEAN_FIELDS + JSONIFY_FIELDS).each do |f|
157
178
  send(:"#{f}=", attributes[f])
158
179
  end
@@ -210,10 +231,10 @@ module PromptBuilder
210
231
  # Add a tool call output to the conversation.
211
232
  #
212
233
  # @param call_id [String] the tool call identifier
213
- # @param result [String, Hash, Array, Content::Base, nil] the tool call result
214
- # @return [Items::FunctionOutput] the added function output item
234
+ # @param result [String, Array<Content::Base, Hash>, nil] the tool call result
235
+ # @return [Items::FunctionCallOutput] the added function call output item
215
236
  def add_function_call_output(call_id:, result:)
216
- add_item(Items::FunctionOutput.new(call_id: call_id, result: result))
237
+ add_item(Items::FunctionCallOutput.new(call_id: call_id, output: result))
217
238
  end
218
239
 
219
240
  # Add a raw item to the conversation.
@@ -286,6 +307,118 @@ module PromptBuilder
286
307
  end
287
308
  end
288
309
 
310
+ # Copy tool definitions from a ToolRegistry onto this session by name.
311
+ # With no names, all tools in the registry are copied (same as
312
+ # +register_tools+). Definitions are copied, so later registry changes do
313
+ # not affect the session and the tools survive +to_h+/+from_h+ round-trips.
314
+ #
315
+ # @param names [Array<String, Symbol>] the tool names to copy; empty for all
316
+ # @param registry [ToolRegistry, nil] the registry to copy from; defaults
317
+ # to the global +PromptBuilder.tool_registry+
318
+ # @return [Array<Tools::Definition>] the definitions registered on the session
319
+ # @raise [ToolNotFoundError] if a name is not registered in the registry
320
+ # @example
321
+ # session.use_tools("weather", "traffic_conditions")
322
+ # session.use_tools # all registry tools
323
+ # session.use_tools(:weather, registry: my_registry)
324
+ def use_tools(*names, registry: nil)
325
+ registry ||= PromptBuilder.tool_registry
326
+ raise ArgumentError, "registry must be an instance of ToolRegistry" unless registry.is_a?(ToolRegistry)
327
+
328
+ definitions = if names.empty?
329
+ registry.definitions
330
+ else
331
+ names.map do |name|
332
+ registry.definition_for(name.to_s) ||
333
+ raise(ToolNotFoundError, "No tool registered with name: #{name.to_s.inspect}")
334
+ end
335
+ end
336
+
337
+ definitions.map do |defn|
338
+ extra = defn.extra.transform_keys(&:to_sym)
339
+ register_tool(
340
+ defn.name,
341
+ description: defn.description,
342
+ parameters: defn.parameters,
343
+ strict: defn.strict,
344
+ **extra
345
+ )
346
+ end
347
+ end
348
+
349
+ # Configure JSON Schema structured output. Writes the canonical
350
+ # +text.format+ wire hash consumed by all serializers, preserving any
351
+ # other +text+ keys (e.g. +verbosity+) already set.
352
+ #
353
+ # @param schema [Hash] the JSON Schema for the response
354
+ # @param name [String] the schema name
355
+ # @param strict [Boolean, nil] whether strict schema adherence is requested;
356
+ # omitted from the format when nil
357
+ # @param description [String, nil] an optional schema description
358
+ # @return [Hash] the resulting +text+ configuration
359
+ # @example
360
+ # session.json_output({"type" => "object", "properties" => {...}}, strict: true)
361
+ def json_output(schema, name: "response", strict: nil, description: nil)
362
+ format = {"type" => "json_schema", "name" => name.to_s, "schema" => schema}
363
+ format["strict"] = strict unless strict.nil?
364
+ format["description"] = description.to_s if description
365
+ self.text = (text || {}).merge("format" => format)
366
+ end
367
+
368
+ # Configure reasoning/extended thinking portably across serializers.
369
+ # Stores a normalized +reasoning+ configuration; each serializer maps it
370
+ # to its native parameter:
371
+ #
372
+ # - +effort+ — Messages (+output_config.effort+), Chat Completions
373
+ # (+reasoning_effort+), Gemini (+thinkingConfig.thinkingLevel+), and
374
+ # Open Responses (+reasoning.effort+)
375
+ # - +budget_tokens+ — Messages (+thinking.budget_tokens+) and Gemini
376
+ # (+thinkingConfig.thinkingBudget+); ignored by effort-based APIs
377
+ # - Converse supports neither and warns once at serialization time
378
+ #
379
+ # Pass either +effort+ or +budget_tokens+, not both — providers reject
380
+ # requests that set both controls. Direct +session.reasoning = {...}+
381
+ # assignment keeps working for provider-specific keys.
382
+ #
383
+ # @param enabled [Boolean] pass +false+ to clear the reasoning configuration
384
+ # @param effort [String, Symbol, nil] a portable effort level; one of
385
+ # +minimal+, +low+, +medium+, +high+, +xhigh+, +max+ (unsupported levels
386
+ # are omitted by serializers whose target API does not accept them)
387
+ # @param budget_tokens [Integer, nil] an explicit thinking token budget
388
+ # @return [Hash, nil] the resulting +reasoning+ configuration
389
+ # @raise [ArgumentError] if neither or both of +effort+ and +budget_tokens+
390
+ # are given when enabling, or the values are invalid
391
+ # @example
392
+ # session.think(effort: :medium) # portable across serializers
393
+ # session.think(budget_tokens: 8_000) # explicit where supported
394
+ # session.think(false) # disable
395
+ def think(enabled = true, effort: nil, budget_tokens: nil)
396
+ unless enabled
397
+ raise ArgumentError, "cannot combine think(false) with effort or budget_tokens" if effort || budget_tokens
398
+
399
+ return self.reasoning = nil
400
+ end
401
+
402
+ if effort && budget_tokens
403
+ raise ArgumentError, "pass either effort or budget_tokens, not both"
404
+ elsif effort.nil? && budget_tokens.nil?
405
+ raise ArgumentError, "think requires effort: or budget_tokens:"
406
+ end
407
+
408
+ if effort
409
+ effort = effort.to_s
410
+ unless THINK_EFFORT_LEVELS.include?(effort)
411
+ raise ArgumentError, "effort must be one of: #{THINK_EFFORT_LEVELS.join(", ")}"
412
+ end
413
+ self.reasoning = {"effort" => effort}
414
+ else
415
+ budget_tokens = Integer(budget_tokens)
416
+ raise ArgumentError, "budget_tokens must be positive" unless budget_tokens.positive?
417
+
418
+ self.reasoning = {"budget_tokens" => budget_tokens}
419
+ end
420
+ end
421
+
289
422
  # Return all tool definitions registered on this session.
290
423
  #
291
424
  # @return [Array<Tools::Definition>] all tool definitions
@@ -44,14 +44,16 @@ module PromptBuilder
44
44
  "data:#{content_type};base64,#{[data].pack("m0")}"
45
45
  end
46
46
 
47
- # Parse a data URL into its media type and base64-encoded data.
47
+ # Parse a data URL into its media type and base64-encoded data. Media type
48
+ # parameters (e.g. "data:text/plain;charset=utf-8;base64,...") are allowed
49
+ # and excluded from the returned media type.
48
50
  #
49
51
  # @param url [String, nil] a URL that may be a data URL
50
52
  # @return [Array(String, String), nil] a two-element array of +[media_type, data]+
51
- # or nil if the URL is not a data URL
53
+ # or nil if the URL is not a base64-encoded data URL
52
54
  def parse_data_url(url)
53
55
  return nil unless url
54
- match = url.match(/\Adata:([^;]+);base64,(.*)\z/m)
56
+ match = url.match(/\Adata:([^;,]+)(?:;[^,]*)?;base64,(.*)\z/m)
55
57
  return nil unless match
56
58
  [match[1], match[2]]
57
59
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: prompt_builder
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.1
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Brian Durand