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 +4 -4
- data/CHANGELOG.md +34 -0
- data/README.md +80 -10
- data/VERSION +1 -1
- data/lib/prompt_builder/errors.rb +3 -0
- data/lib/prompt_builder/response.rb +67 -0
- data/lib/prompt_builder/serializers/chat_completion/request.rb +18 -3
- data/lib/prompt_builder/serializers/chat_completion/response.rb +7 -2
- data/lib/prompt_builder/serializers/converse/request.rb +38 -11
- data/lib/prompt_builder/serializers/gemini/request.rb +10 -1
- data/lib/prompt_builder/serializers/messages/request.rb +41 -3
- data/lib/prompt_builder/serializers/open_responses/request.rb +74 -26
- data/lib/prompt_builder/session.rb +137 -4
- data/lib/prompt_builder.rb +5 -3
- 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,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.
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
735
|
-
- `usage.cacheReadInputTokens` and `usage.cacheWriteInputTokens` populate `response.usage.input_tokens_details["cached_tokens"]` and `["cache_creation_input_tokens"]`. Cache writes
|
|
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
|
+
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.
|
|
@@ -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
|
-
|
|
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
|
|
@@ -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 (+
|
|
61
|
-
# - Per-model passthrough fields (+
|
|
62
|
-
# - Requested provider response fields (+
|
|
63
|
-
# -
|
|
64
|
-
# -
|
|
65
|
-
#
|
|
66
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
-
#
|
|
40
|
-
#
|
|
41
|
-
#
|
|
42
|
-
|
|
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.
|
|
46
|
-
next unless
|
|
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.
|
|
69
|
+
strip_extra_keys!(item_hash, item.extra)
|
|
49
70
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
strip_extra_from_blocks!(
|
|
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
|
-
|
|
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
|
|
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,
|
|
214
|
-
# @return [Items::
|
|
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::
|
|
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
|
data/lib/prompt_builder.rb
CHANGED
|
@@ -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:([
|
|
56
|
+
match = url.match(/\Adata:([^;,]+)(?:;[^,]*)?;base64,(.*)\z/m)
|
|
55
57
|
return nil unless match
|
|
56
58
|
[match[1], match[2]]
|
|
57
59
|
end
|