prompt_builder 0.1.2 → 0.3.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: e762383ea51918fa77b5962f0137775551a37da1c228a37d24364011c8d21f73
4
- data.tar.gz: 64e2a4e22feb0a8e0e5752f4807b79452c3cb700dd8b5740500f11cbe0b4217e
3
+ metadata.gz: a6a857e2a0958425d04263986bca11967fb575af1c1e40ada5bee63556447fe0
4
+ data.tar.gz: 9840a2750ac27e265aea1e25d9290ee3aecb56cd24889d5903b481891a2925db
5
5
  SHA512:
6
- metadata.gz: b7eb3a4806f9cd7a24e00652659b7a5d641ae5877187bd714afd0e4eaa86c7a7901eebcfdf11202497d0b98cce03f9a0fec10f1840883f91b4b87be3a9f2c387
7
- data.tar.gz: 2b731def4d25b2233b208d3fb3d8867ee17cce9356cb84b3d57d3d3043116af5eb55ca7888de00c329c3637b209bbc70ea11dada040bc5f97f9701c85a0fa8ee
6
+ metadata.gz: 70fe6418fdd6865e12ec8babb5d5a7542ff31e93b44e0dfb389c1e0853955d5657bbf3482462e1ec091fca8031ac988bfc70222195a8ad2fd50833eb2542c4a1
7
+ data.tar.gz: bd0d9abb2cbb42d10d70c8f1d15322cc58a4c88c6401f937a45844fbde2dcf8013b183cf19d206b1e4c0e48cf9c0a03fb3ad5b35c17ed083b12d7c73f00224c6
data/CHANGELOG.md CHANGED
@@ -4,6 +4,51 @@ 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.3.0
8
+
9
+ ### Added
10
+
11
+ - Response parsers now recognize API error payloads (OpenAI/Gemini `error` envelopes, Anthropic `type: "error"` responses, Bedrock exception bodies) and raise a `PromptBuilder::ErrorResponseError` (a subclass of `UnexpectedPayloadError`) containing the error message reported by the API.
12
+ - `Session.new` accepts a `system:` shorthand that adds a system message, mirroring the existing `input:` shorthand.
13
+ - `Session#clear` resets the conversation — items, `instructions`, and `previous_response_id` — while preserving model configuration and registered tools.
14
+ - `Session#remove_tool` removes a registered tool by name (string or symbol); `Session#clear_tools` removes them all. Both return the removed definitions.
15
+ - `Session#extra=` sets or clears provider-specific extra data after the session has been constructed. Keys are normalized to strings; assigning `nil` clears the data.
16
+ - Message content hashes with a `text` key no longer require a `type` key; the type defaults to `input_text` (or `output_text` on assistant messages).
17
+ - `Items::Message#system?`, `#user?`, and `#assistant?` role predicates.
18
+ - The Converse serializer emits `cachePoint` markers from the `cache_point` extra on tool definitions and `FunctionCallOutput` items, in addition to content blocks.
19
+ - `Session#to_h`/`Session.from_h` now round-trip the response boundary (`response_boundary_index`), and a `response_boundary_index=` setter is available for reconstructing session state.
20
+ - `Session::INITIALIZE_OPTIONS` is now public API: the frozen Array of all keyword options accepted by the constructor, for code that needs to validate or partition an options hash before creating a session.
21
+
22
+ ### Changed
23
+
24
+ - `Session#extra` always returns a Hash (previously `nil` when unset). The returned Hash is a copy; assign a modified copy back through `Session#extra=` to change it.
25
+ - `Session#to_h` omits `extra` when it is empty.
26
+ - `instructions` is now serialized after system/developer messages instead of before them, matching the Open Responses semantics of `instructions` applying to the current request.
27
+
28
+ ### Fixed
29
+
30
+ - Serializers silently dropped `Content::Text` content (hashes with `type: "text"`) from messages, system prompts, and tool results; it is now serialized as text everywhere `InputText`/`OutputText` are, including cache markers from content extras. The Messages and Gemini serializers also dropped `OutputText` content from system messages.
31
+ - Tool names are normalized to strings on registration, so registering the same tool by symbol and by string no longer creates duplicate definitions.
32
+
33
+ ## 0.2.0
34
+
35
+ ### Added
36
+
37
+ - `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.
38
+ - `Session#json_output` configures JSON Schema structured output without writing the raw `text.format` wire hash by hand.
39
+ - `Session#think` configures reasoning portably across serializers via `effort:` or `budget_tokens:`; `think(false)` clears the configuration.
40
+ - `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.
41
+ - `Response.from_text` synthesizes a completed assistant text response for canned answers, cached responses, and tests.
42
+ - `PromptBuilder::ParseError` error class.
43
+
44
+ ### Changed
45
+
46
+ - `Session.new` raises an `ArgumentError` when passed an unsupported keyword option instead of silently ignoring it.
47
+ - The Messages serializer raises an `UnsupportedFormatError` when `session.max_output_tokens` is not set, since the Messages API requires the `max_tokens` parameter.
48
+ - 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.
49
+ - 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.
50
+ - The Converse serializer warns once per process when `session.reasoning` is set instead of silently dropping it (the Converse endpoint has no reasoning parameter).
51
+
7
52
  ## 0.1.2
8
53
 
9
54
  ### Fixed
data/README.md CHANGED
@@ -48,15 +48,29 @@ session = PromptBuilder::Session.new(
48
48
  session.user("What is the capital of France?")
49
49
  ```
50
50
 
51
- You can also pass an `input` shorthand to create a user message in one step:
51
+ You can also pass `system` and `input` shorthands to create a system message and a user message in one step (equivalent to calling `session.system(...)` and `session.user(...)`):
52
52
 
53
53
  ```ruby
54
54
  session = PromptBuilder::Session.new(
55
55
  model: "gpt-5.4",
56
+ system: "You are a helpful assistant.",
56
57
  input: "What is the capital of France?"
57
58
  )
58
59
  ```
59
60
 
61
+ Passing an option the constructor doesn't recognize raises an `ArgumentError`. The full list of supported constructor options is available as `PromptBuilder::Session::INITIALIZE_OPTIONS`.
62
+
63
+ #### `instructions` vs. system and developer messages
64
+
65
+ Although both can be used to steer the model's behavior, `instructions` and system/developer messages are not interchangeable in the Open Responses API:
66
+
67
+ - `instructions` is a top-level request parameter, not part of the conversation. It is sent with every request, and in the Open Responses API it applies only to the request it accompanies — it is *not* carried over when chaining responses with `previous_response_id`. That makes it safe to change `session.instructions` at any point in a conversation; the new value simply applies from the next request onward.
68
+ - `session.system(...)` and `session.developer(...)` create messages inside the conversation history (the `input` array). They occupy a position in the transcript like any other message, and once the session is chained with `previous_response_id` they become part of the server-stored history and are not re-sent on subsequent requests.
69
+
70
+ As a rule of thumb, use `instructions` for standing behavior you may want to adjust from request to request, and use system or developer messages when the directive should be a durable part of the conversation record.
71
+
72
+ When serializing to the other formats this distinction collapses: `instructions` and any system/developer messages are merged together into the target format's single system field (see [Serializer Compatibility](#serializer-compatibility)).
73
+
60
74
  ### Conversation History
61
75
 
62
76
  Build up a multi-turn conversation by adding messages:
@@ -69,7 +83,13 @@ session.assistant("Hi there! How can I help you today?")
69
83
  session.user("What's the weather like?")
70
84
  ```
71
85
 
72
- Messages support the roles `user`, `assistant`, `system`, and `developer`.
86
+ Messages support the roles `user`, `assistant`, `system`, and `developer`. A `Message` exposes `system?`, `user?`, and `assistant?` predicates for checking its role.
87
+
88
+ Call `session.clear` to reset the conversation: it removes all items and the `instructions`, drops any `previous_response_id`, and returns the session to a fresh local-state start. Model configuration and registered tools are preserved, so the session can be reused for a new conversation.
89
+
90
+ ```ruby
91
+ session.clear # items and instructions removed; model/config/tools kept
92
+ ```
73
93
 
74
94
  ### Serializing Requests
75
95
 
@@ -160,6 +180,59 @@ response.has_tool_calls? # => false
160
180
  response.usage # => #<PromptBuilder::Usage input_tokens=25 output_tokens=12 ...>
161
181
  ```
162
182
 
183
+ If the payload is an error returned by the API rather than a completion (e.g. an authentication failure, a validation error, or an AWS Coral service envelope), `Response.parse` raises a `PromptBuilder::ErrorResponseError` whose message contains the error reported by the API. Payloads that are neither a completion nor a recognizable error envelope raise a `PromptBuilder::UnexpectedPayloadError` (which `ErrorResponseError` subclasses) including the offending body.
184
+
185
+ ```ruby
186
+ PromptBuilder::Response.parse({"error" => {"type" => "invalid_request_error", "message" => "Incorrect API key provided"}}, :chat_completion)
187
+ # => raises PromptBuilder::ErrorResponseError: the API returned an error: invalid_request_error: Incorrect API key provided
188
+ ```
189
+
190
+ 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:
191
+
192
+ ```ruby
193
+ response = PromptBuilder::Response.from_text("Authentication failed.",
194
+ model: llm_response.model, usage: llm_response.usage)
195
+ response.text # => "Authentication failed."
196
+ response.completed? # => true
197
+ ```
198
+
199
+ ### Structured Output
200
+
201
+ Use `json_output` to request JSON Schema structured output and `parsed_json` to read it back:
202
+
203
+ ```ruby
204
+ schema = {
205
+ "type" => "object",
206
+ "properties" => {"answer" => {"type" => "string"}},
207
+ "required" => ["answer"]
208
+ }
209
+
210
+ session.json_output(schema, name: "response", strict: true)
211
+ payload = session.request_payload(:messages) # works with all five serializers
212
+
213
+ # After parsing the API response:
214
+ data = response.parsed_json # => {"answer" => "..."} or nil if the text isn't valid JSON
215
+ data = response.parsed_json! # raises PromptBuilder::ParseError (including the raw text) instead
216
+ ```
217
+
218
+ `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.
219
+
220
+ ### Reasoning / Extended Thinking
221
+
222
+ Use `think` to configure reasoning portably; each serializer maps it to its native parameter:
223
+
224
+ ```ruby
225
+ session.think(effort: :medium) # portable across serializers
226
+ session.think(budget_tokens: 8_000) # explicit token budget where supported
227
+ session.think(false) # clear the reasoning configuration
228
+ ```
229
+
230
+ - `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.
231
+ - `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`.
232
+ - The Converse API has no reasoning parameter; the serializer warns once per process and omits the configuration.
233
+
234
+ Direct `session.reasoning = {...}` assignment keeps working for provider-specific keys (e.g. Anthropic's `display`, Gemini's `summary`).
235
+
163
236
  ### Agentic Tool Loops
164
237
 
165
238
  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 +313,23 @@ end
240
313
  session.register_tools(PromptBuilder.tool_registry)
241
314
  ```
242
315
 
316
+ 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:
317
+
318
+ ```ruby
319
+ session.use_tools("weather", "traffic_conditions") # from PromptBuilder.tool_registry
320
+ session.use_tools # all tools in the registry
321
+ session.use_tools(:weather, registry: my_registry) # explicit registry
322
+ ```
323
+
324
+ 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.
325
+
326
+ Remove tools from a session with `remove_tool` (by name; accepts a string or symbol) or `clear_tools` (all at once). `remove_tool` returns the removed `Tools::Definition`, or `nil` if no tool by that name was registered; `clear_tools` returns the removed definitions.
327
+
328
+ ```ruby
329
+ session.remove_tool("weather") # => removed Tools::Definition, or nil
330
+ session.clear_tools # => [<removed definitions>]
331
+ ```
332
+
243
333
  ### Content Types
244
334
 
245
335
  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.
@@ -360,6 +450,8 @@ session = PromptBuilder::Session.new(
360
450
 
361
451
  The `extra` attribute on sessions, content blocks, and tool definitions lets you pass provider-specific parameters that are not part of the Open Responses canonical format. Each serializer recognizes a defined set of `extra` keys and maps them to the appropriate location in the target format. Unrecognized keys are silently ignored.
362
452
 
453
+ On a session, `extra` is a read/write attribute that can be set at any point in the session's life. On content blocks and tool definitions it is set with keyword arguments at construction time.
454
+
363
455
  #### Session Extra
364
456
 
365
457
  Pass provider-specific top-level request parameters via `session.extra`:
@@ -413,6 +505,26 @@ session = PromptBuilder::Session.new(
413
505
  )
414
506
  ```
415
507
 
508
+ The same data can be set after the session has been created by assigning to `session.extra`. Keys are normalized to strings, so symbol keys work too.
509
+
510
+ ```ruby
511
+ session = PromptBuilder::Session.new(model: "anthropic.claude-v2")
512
+
513
+ session.extra # => {}
514
+
515
+ # Assign the whole hash.
516
+ session.extra = {guardrail_config: {"guardrailIdentifier" => "my-guardrail", "guardrailVersion" => "1"}}
517
+
518
+ # `extra` returns a copy, so read, modify, and assign it back to change one key.
519
+ session.extra = session.extra.merge("stop_sequences" => ["\n\nHuman:"])
520
+ session.extra = session.extra.except("stop_sequences")
521
+
522
+ session.extra = nil # clears it
523
+ ```
524
+
525
+ > [!IMPORTANT]
526
+ > `session.extra` never returns `nil` and its keys are always strings. Because the reader returns a copy, mutating it in place has no effect on the session — `session.extra["seed"] = 42` is silently discarded. Assign the modified hash back as shown above.
527
+
416
528
  #### Content Extra
417
529
 
418
530
  Content blocks support provider-specific attributes via keyword arguments that are captured in the `extra` hash:
@@ -442,6 +554,21 @@ session.user([
442
554
  cache_point: true
443
555
  )
444
556
  ])
557
+
558
+ # Bedrock Converse: cache_point on tool definitions and tool results
559
+ session.register_tool(
560
+ "search",
561
+ description: "Search the knowledge base.",
562
+ parameters: {"type" => "object", "properties" => {"query" => {"type" => "string"}}},
563
+ cache_point: true
564
+ )
565
+ session.add_item(
566
+ PromptBuilder::Items::FunctionCallOutput.new(
567
+ call_id: "call_1",
568
+ output: "Search results...",
569
+ cache_point: true
570
+ )
571
+ )
445
572
  ```
446
573
 
447
574
  #### Tool Definition Extra
@@ -460,12 +587,14 @@ session.register_tool(
460
587
 
461
588
  #### Recognized Extra Keys by Serializer
462
589
 
590
+ Session extra keys can be supplied to the constructor or assigned later with `session.extra=`.
591
+
463
592
  | Serializer | Session Extra Keys | Content Extra Keys | Tool Extra Keys |
464
593
  |:---|:---|:---|:---|
465
594
  | **Chat Completions** | `stop`, `seed`, `logit_bias`, `n`, `prediction`, `web_search_options`, `modalities`, `audio` | `file_id`, `media_type` | — |
466
595
  | **Messages** | `top_k`, `stop_sequences`, `cache_control` | `cache_control`, `citations`, `file_id`, `media_type` | `cache_control` |
467
596
  | **Gemini** | `safety_settings`, `cached_content`, `stop_sequences`, `top_k`, `seed`, `candidate_count`, `response_modalities`, `media_resolution` | `file_id`, `media_type`, `thought_signature` | — |
468
- | **Converse** | `stop_sequences`, `guardrail_config`, `additional_model_request_fields`, `additional_model_response_field_paths`, `performance_config`, `prompt_variables` | `media_type`, `cache_point` | |
597
+ | **Converse** | `stop_sequences`, `guardrail_config`, `additional_model_request_fields`, `additional_model_response_field_paths`, `performance_config`, `prompt_variables` | `media_type`, `cache_point` | `cache_point` |
469
598
 
470
599
  ### Serialization and Persistence
471
600
 
@@ -481,6 +610,9 @@ restored_session = PromptBuilder::Session.from_h(hash)
481
610
 
482
611
  This makes it straightforward to persist conversation state in a database or cache between requests.
483
612
 
613
+ > [!WARNING]
614
+ > `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.
615
+
484
616
  ## Serializer Compatibility
485
617
 
486
618
  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 +733,8 @@ For Messages specifically:
601
733
 
602
734
  ### Chat Completions-specific notes
603
735
 
736
+ Serializing a session without `model` set, or one that produces no messages at all, raises a `PromptBuilder::UnsupportedFormatError`.
737
+
604
738
  Request-side mappings worth calling out:
605
739
 
606
740
  | Canonical field / value | Chat Completions mapping |
@@ -631,6 +765,8 @@ Response-side behavior and limitations:
631
765
 
632
766
  ### Anthropic Messages-specific mappings
633
767
 
768
+ 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`.
769
+
634
770
  A few features map between the canonical Open Responses format and the Messages API in non-obvious ways:
635
771
 
636
772
  | Canonical field / value | Messages mapping |
@@ -654,6 +790,8 @@ Built-in tool response content blocks (`server_tool_use`, `web_search_tool_resul
654
790
 
655
791
  ### Gemini-specific notes
656
792
 
793
+ 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`.
794
+
657
795
  Request-side mappings worth calling out:
658
796
 
659
797
  | Canonical field / value | Gemini mapping |
@@ -697,6 +835,8 @@ Response-side limitations:
697
835
 
698
836
  ### Converse-specific notes
699
837
 
838
+ Serializing a session without `model` set (populates the required `modelId` parameter) raises a `PromptBuilder::UnsupportedFormatError`.
839
+
700
840
  Request-side restrictions worth calling out:
701
841
 
702
842
  | Canonical field / value | Converse mapping |
@@ -725,7 +865,7 @@ Content and message restrictions:
725
865
  - `FunctionCall.arguments` must parse to a JSON object; non-object JSON values raise (Bedrock's `toolUse.input` requires an object).
726
866
  - `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
867
  - `tool_choice: "none"` and `tool_choice` without registered tools are both omitted.
728
- - 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.
868
+ - 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`). `cache_point` is also recognized on tool definitions (the marker is appended to the `toolConfig.tools` array) and on `FunctionCallOutput` items (the marker is appended after the `toolResult` content block). Features not exposed at all include `guardContent`, audio blocks, and `searchResult` blocks.
729
869
  - 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.
730
870
 
731
871
  Response-side limitations:
@@ -733,7 +873,7 @@ Response-side limitations:
733
873
  - Unknown content block keys (e.g. `citationsContent`, `guardContent`) are silently skipped.
734
874
  - `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`.
735
875
  - `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`).
736
- - `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.
876
+ - `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` extra on content, tool definitions, and `FunctionCallOutput` items.
737
877
 
738
878
  ## Installation
739
879
 
data/VERSION CHANGED
@@ -1 +1 @@
1
- 0.1.2
1
+ 0.3.0
@@ -18,4 +18,12 @@ 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 when a response payload is an error returned by the API rather
23
+ # than a completion (e.g. an authentication, validation, or throttling
24
+ # error envelope). The message contains the error reported by the API.
25
+ class ErrorResponseError < UnexpectedPayloadError; end
26
+
27
+ # Raised by Response#parsed_json! when the response text is not valid JSON.
28
+ class ParseError < Error; end
21
29
  end
@@ -23,6 +23,24 @@ module PromptBuilder
23
23
  # @return [Hash, nil] provider-specific extra data
24
24
  attr_reader :extra
25
25
 
26
+ class << self
27
+ # Deserialize a Message from a Hash.
28
+ #
29
+ # @param hash [Hash] a Hash with string keys
30
+ # @return [Message]
31
+ def from_h(hash)
32
+ content = (hash["content"] || []).map { |c| Content::Base.from_h(c) }
33
+ new(
34
+ id: hash["id"],
35
+ role: hash["role"],
36
+ status: hash["status"],
37
+ phase: hash["phase"],
38
+ content: content,
39
+ **hash.except("type", "id", "role", "status", "phase", "content").transform_keys(&:to_sym)
40
+ )
41
+ end
42
+ end
43
+
26
44
  # Create a new Message item.
27
45
  #
28
46
  # @param id [String, nil] the message identifier
@@ -40,22 +58,25 @@ module PromptBuilder
40
58
  @extra = extra.transform_keys(&:to_s)
41
59
  end
42
60
 
43
- class << self
44
- # Deserialize a Message from a Hash.
45
- #
46
- # @param hash [Hash] a Hash with string keys
47
- # @return [Message]
48
- def from_h(hash)
49
- content = (hash["content"] || []).map { |c| Content::Base.from_h(c) }
50
- new(
51
- id: hash["id"],
52
- role: hash["role"],
53
- status: hash["status"],
54
- phase: hash["phase"],
55
- content: content,
56
- **hash.except("type", "id", "role", "status", "phase", "content").transform_keys(&:to_sym)
57
- )
58
- end
61
+ # Check if the message is a system message.
62
+ #
63
+ # @return [Boolean]
64
+ def system?
65
+ role == "system"
66
+ end
67
+
68
+ # Check if the message is a user message.
69
+ #
70
+ # @return [Boolean]
71
+ def user?
72
+ role == "user"
73
+ end
74
+
75
+ # Check if the message is an assistant message.
76
+ #
77
+ # @return [Boolean]
78
+ def assistant?
79
+ role == "assistant"
59
80
  end
60
81
 
61
82
  # Serialize to a Hash with string keys.
@@ -102,6 +123,9 @@ module PromptBuilder
102
123
 
103
124
  def normalize_hash_content(hash)
104
125
  hash = hash.transform_keys(&:to_s)
126
+ if hash["type"].nil? && hash.key?("text")
127
+ hash = hash.merge("type" => (assistant? ? "output_text" : "input_text"))
128
+ end
105
129
  unless hash["type"]
106
130
  raise InvalidItemError,
107
131
  "Content hash is missing required \"type\" key: #{hash.inspect}"
@@ -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
 
@@ -18,7 +18,9 @@ module PromptBuilder
18
18
  #
19
19
  # @param hash [Hash] the response hash in the target format
20
20
  # @return [Response] the parsed response
21
+ # @raise [ErrorResponseError] if the payload is an API error envelope
21
22
  def parse_response(hash)
23
+ check_error_response!(hash)
22
24
  deserialize_response(hash)
23
25
  end
24
26
 
@@ -32,6 +34,32 @@ module PromptBuilder
32
34
  raise NotImplementedError
33
35
  end
34
36
 
37
+ # Detect API error envelopes before parsing so errors surface with the
38
+ # message reported by the API instead of the generic missing-key error
39
+ # from require_response_key!. Serializer response classes override
40
+ # error_response_message to recognize their format's error envelope.
41
+ #
42
+ # @param hash [Object] the raw response payload
43
+ # @return [void]
44
+ # @raise [ErrorResponseError] if the payload is an API error envelope
45
+ def check_error_response!(hash)
46
+ return unless hash.is_a?(Hash)
47
+
48
+ message = error_response_message(hash)
49
+ return if message.nil? || message.empty?
50
+
51
+ raise ErrorResponseError, "the API returned an error: #{message}"
52
+ end
53
+
54
+ # Extract a human-readable message from an API error envelope.
55
+ #
56
+ # @param _hash [Hash] the raw response payload
57
+ # @return [String, nil] nil when the payload is not recognized as an
58
+ # error envelope
59
+ def error_response_message(_hash)
60
+ nil
61
+ end
62
+
35
63
  # Ensure a response payload is a Hash containing the key that identifies
36
64
  # it as a well-formed response for this format. Raised before parsing so
37
65
  # malformed bodies (e.g. provider error envelopes) fail loudly with the
@@ -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
@@ -22,6 +27,10 @@ module PromptBuilder
22
27
  # supported, and only when +stream+ is set (otherwise it is omitted)
23
28
  #
24
29
  # Input content restrictions:
30
+ # - +instructions+ is serialized as a system message inserted after the
31
+ # last system/developer message, or first when there are none
32
+ # (instructions apply to the current request, matching Open Responses
33
+ # semantics)
25
34
  # - +InputVideo+ content is not supported in any message (omitted)
26
35
  # - +Reasoning+ items are not supported (skipped)
27
36
  # - +RefusalContent+ is dropped silently (a parsed Chat Completions
@@ -62,8 +71,14 @@ module PromptBuilder
62
71
 
63
72
  def serialize_request(session)
64
73
  h = {}
65
- h["model"] = session.model if session.model
66
- h["messages"] = build_messages(session)
74
+ raise UnsupportedFormatError, "Chat Completions format requires session.model" unless session.model
75
+
76
+ h["model"] = session.model
77
+ messages = build_messages(session)
78
+ if messages.empty?
79
+ raise UnsupportedFormatError, "Chat Completions format requires at least one message"
80
+ end
81
+ h["messages"] = messages
67
82
  h["temperature"] = session.temperature if session.temperature
68
83
  h["top_p"] = session.top_p if session.top_p
69
84
  h["presence_penalty"] = session.presence_penalty if session.presence_penalty
@@ -108,7 +123,8 @@ module PromptBuilder
108
123
  end
109
124
 
110
125
  # Session extra: recognized keys for Chat Completions API
111
- apply_session_extra!(h, session.extra) if session.extra
126
+ extra = session.extra
127
+ apply_session_extra!(h, extra) unless extra.empty?
112
128
 
113
129
  h
114
130
  end
@@ -127,10 +143,6 @@ module PromptBuilder
127
143
  def build_messages(session)
128
144
  messages = []
129
145
 
130
- if session.instructions
131
- messages << {"role" => "system", "content" => session.instructions}
132
- end
133
-
134
146
  pending_tool_calls = []
135
147
  last_assistant_msg = nil
136
148
 
@@ -160,9 +172,18 @@ module PromptBuilder
160
172
  end
161
173
 
162
174
  flush_tool_calls!(messages, pending_tool_calls, last_assistant_msg)
175
+ insert_instructions!(messages, session.instructions) if session.instructions
163
176
  messages
164
177
  end
165
178
 
179
+ # Instructions apply to the current request (matching Open Responses
180
+ # semantics), so they are inserted after the last system/developer
181
+ # message rather than before any of them.
182
+ def insert_instructions!(messages, instructions)
183
+ index = messages.rindex { |msg| msg["role"] == "system" || msg["role"] == "developer" }
184
+ messages.insert(index ? index + 1 : 0, {"role" => "system", "content" => instructions})
185
+ end
186
+
166
187
  def serialize_message(item)
167
188
  # Messages with an unsupported role are silently skipped.
168
189
  return nil unless SUPPORTED_MESSAGE_ROLES.include?(item.role)
@@ -187,7 +208,7 @@ module PromptBuilder
187
208
 
188
209
  def serialize_content(role, content)
189
210
  case content
190
- when Content::InputText, Content::OutputText
211
+ when Content::InputText, Content::OutputText, Content::Text
191
212
  serialize_text_content(content)
192
213
  when Content::InputImage
193
214
  # Image content is only supported in user messages; omit otherwise.
@@ -230,7 +251,7 @@ module PromptBuilder
230
251
  # are silently omitted.
231
252
  content = output.filter_map do |content|
232
253
  case content
233
- when Content::InputText, Content::OutputText
254
+ when Content::InputText, Content::OutputText, Content::Text
234
255
  serialize_text_content(content)
235
256
  end
236
257
  end