prompt_builder 0.1.2 → 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 +4 -4
- data/CHANGELOG.md +19 -0
- data/README.md +69 -0
- data/VERSION +1 -1
- data/lib/prompt_builder/errors.rb +3 -0
- data/lib/prompt_builder/response.rb +59 -0
- data/lib/prompt_builder/serializers/chat_completion/request.rb +13 -2
- data/lib/prompt_builder/serializers/converse/request.rb +27 -2
- data/lib/prompt_builder/serializers/gemini/request.rb +10 -1
- data/lib/prompt_builder/serializers/messages/request.rb +18 -2
- data/lib/prompt_builder/serializers/open_responses/request.rb +5 -0
- data/lib/prompt_builder/session.rb +134 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 9eee723a908587c28aa7e606b0e6dad45e5958a9d895d29bd42f8d984153d6bc
|
|
4
|
+
data.tar.gz: ffbfa13cc4bdfacc09ace7fe99a46366847faa501db6710cd51cca7123c106b2
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 40023bf88305a391c88b8a189e2810e27ad5af96b2e5bbf3697296eb9b4f63a360239eed8022c343198ddeff09573874988a33372b40b7c15080603a7588f093
|
|
7
|
+
data.tar.gz: b9be150ab138503f29f7769f71d1ad8092cef42aa5ba04ecb4d156f070fa5dc9aecdc1995a31fabfa74ab93206d47c1a24428417e2055d999c4473669c335f59
|
data/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,25 @@ 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
|
+
|
|
7
26
|
## 0.1.2
|
|
8
27
|
|
|
9
28
|
### Fixed
|
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:
|
|
@@ -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.
|
|
@@ -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 |
|
|
@@ -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 |
|
|
@@ -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 |
|
data/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
0.
|
|
1
|
+
0.2.0
|
|
@@ -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.
|
|
@@ -173,6 +175,23 @@ module PromptBuilder
|
|
|
173
175
|
attrs = FIELDS.each_with_object({}) { |f, acc| acc[f] = hash[f.to_s] }
|
|
174
176
|
new(**attrs, text_config: hash["text"], output: output, usage: usage, extra: hash["extra"])
|
|
175
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
|
|
176
195
|
end
|
|
177
196
|
|
|
178
197
|
# Check if the response completed successfully.
|
|
@@ -224,6 +243,40 @@ module PromptBuilder
|
|
|
224
243
|
nil
|
|
225
244
|
end
|
|
226
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
|
+
|
|
227
280
|
# Serialize to a Hash with string keys. Nil values are omitted.
|
|
228
281
|
#
|
|
229
282
|
# @return [Hash]
|
|
@@ -246,6 +299,12 @@ module PromptBuilder
|
|
|
246
299
|
|
|
247
300
|
private
|
|
248
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
|
+
|
|
249
308
|
def coerce_field(field, value)
|
|
250
309
|
return value if value.nil?
|
|
251
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
|
-
|
|
66
|
-
|
|
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
|
|
@@ -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
|
|
@@ -110,6 +114,13 @@ module PromptBuilder
|
|
|
110
114
|
}.freeze
|
|
111
115
|
|
|
112
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
|
+
|
|
113
124
|
private
|
|
114
125
|
|
|
115
126
|
def serialize_request(session)
|
|
@@ -118,7 +129,11 @@ module PromptBuilder
|
|
|
118
129
|
ctx = {document_name_counts: Hash.new(0)}
|
|
119
130
|
|
|
120
131
|
h = {}
|
|
121
|
-
|
|
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
|
|
122
137
|
|
|
123
138
|
system = build_system(session)
|
|
124
139
|
h["system"] = system unless system.empty?
|
|
@@ -154,6 +169,16 @@ module PromptBuilder
|
|
|
154
169
|
h
|
|
155
170
|
end
|
|
156
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
|
+
|
|
157
182
|
def apply_session_extra!(h, extra)
|
|
158
183
|
if extra.key?("stop_sequences")
|
|
159
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
|
-
|
|
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
|
|
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
|
|
@@ -95,7 +101,11 @@ module PromptBuilder
|
|
|
95
101
|
raise UnsupportedFormatError, "Messages format requires session.model" unless session.model
|
|
96
102
|
|
|
97
103
|
h["model"] = session.model
|
|
98
|
-
|
|
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
|
|
99
109
|
h["temperature"] = session.temperature if session.temperature
|
|
100
110
|
h["top_p"] = session.top_p if session.top_p
|
|
101
111
|
effective_metadata = build_effective_metadata(session)
|
|
@@ -249,6 +259,12 @@ module PromptBuilder
|
|
|
249
259
|
def validate_thinking_compatibility!(session, thinking)
|
|
250
260
|
return unless thinking
|
|
251
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
|
+
|
|
252
268
|
if session.temperature
|
|
253
269
|
raise UnsupportedFormatError,
|
|
254
270
|
"Messages format does not support temperature when thinking is enabled"
|
|
@@ -4,6 +4,9 @@ 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
|
|
8
11
|
# Content extra keys that are part of the Open Responses API schema and
|
|
9
12
|
# must survive extra-stripping (the canonical content classes have no
|
|
@@ -20,6 +23,8 @@ module PromptBuilder
|
|
|
20
23
|
# @param session [Session] the session to export
|
|
21
24
|
# @return [Hash] the serialized request payload
|
|
22
25
|
def request_payload(session)
|
|
26
|
+
raise UnsupportedFormatError, "Open Responses format requires session.model" unless session.model
|
|
27
|
+
|
|
23
28
|
payload = session.to_h
|
|
24
29
|
items = apply_server_state!(payload, session)
|
|
25
30
|
payload.delete("extra")
|
|
@@ -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
|
|
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
|
|
@@ -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
|