prompt_builder 0.4.0 → 0.5.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.
Files changed (30) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +30 -0
  3. data/README.md +79 -17
  4. data/VERSION +1 -1
  5. data/lib/prompt_builder/errors.rb +9 -1
  6. data/lib/prompt_builder/response.rb +24 -0
  7. data/lib/prompt_builder/serializers/base.rb +46 -0
  8. data/lib/prompt_builder/serializers/chat_completion/request.rb +20 -6
  9. data/lib/prompt_builder/serializers/chat_completion/response.rb +3 -1
  10. data/lib/prompt_builder/serializers/chat_completion/stream_accumulator.rb +143 -0
  11. data/lib/prompt_builder/serializers/chat_completion.rb +1 -0
  12. data/lib/prompt_builder/serializers/converse/request.rb +195 -23
  13. data/lib/prompt_builder/serializers/converse/stream_accumulator.rb +151 -0
  14. data/lib/prompt_builder/serializers/converse.rb +1 -0
  15. data/lib/prompt_builder/serializers/gemini/request.rb +26 -8
  16. data/lib/prompt_builder/serializers/gemini/stream_accumulator.rb +188 -0
  17. data/lib/prompt_builder/serializers/gemini.rb +1 -0
  18. data/lib/prompt_builder/serializers/messages/request.rb +95 -33
  19. data/lib/prompt_builder/serializers/messages/stream_accumulator.rb +110 -0
  20. data/lib/prompt_builder/serializers/messages.rb +1 -0
  21. data/lib/prompt_builder/serializers/open_responses/request.rb +25 -0
  22. data/lib/prompt_builder/serializers/open_responses/stream_accumulator.rb +55 -0
  23. data/lib/prompt_builder/serializers/open_responses.rb +1 -0
  24. data/lib/prompt_builder/session.rb +49 -31
  25. data/lib/prompt_builder/streaming/accumulator.rb +169 -0
  26. data/lib/prompt_builder/streaming/event_stream_decoder.rb +137 -0
  27. data/lib/prompt_builder/streaming/sse_parser.rb +152 -0
  28. data/lib/prompt_builder/streaming.rb +14 -0
  29. data/lib/prompt_builder.rb +1 -0
  30. metadata +10 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 81ca025b0770c1b045ba525bc7fb6839a25b2d0b760b06ba85698de84c0a36b7
4
- data.tar.gz: e8fc9eded433a202f7b2c6e9c79d0a0f8a9325b156cc884d09e1f7af535679a0
3
+ metadata.gz: aeeec3cd90a84a7b1daf32891039ca8338176ebf4b682a00e02110673eac2d53
4
+ data.tar.gz: 4db3f0193cd06736840bb06a16101278a9124fcbd3db131eb05dfc7965cdf5b8
5
5
  SHA512:
6
- metadata.gz: e5eab3ec44e9c2a958b60d6c8c24dd740c8586eef8e5d05b6d0ab67807bc5526c3946b8ddd50439d60a860fdcc96e7372d637c552d15938383ef36ecfa173486
7
- data.tar.gz: b0aaf0e28017996c603101d8fbde94fc4c242ffa42d471caa7cb0bbaad55f812962873237cf839d51755a86d844ec4fe7676516d3ed8c87df322a6bcd33b424c
6
+ metadata.gz: 99dfc3d20c0b1535939832b58bde93c3aec58527b4fa469698d96d03f0f44b316f6819093f48a128b1498d19ae463728fde048dcecdfa3e30d57b9c9157c11af
7
+ data.tar.gz: f31a14885a75c8a0306237cd62840e87912336db42104438e96111675d2e9eec5868f96b6e23f21e6cdb74c76edb29ac5a43c59cf4045cb9ea216ba223cda466
data/CHANGELOG.md CHANGED
@@ -4,6 +4,36 @@ 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.5.0
8
+
9
+ ### Added
10
+
11
+ - `Response.parse_stream(body, serializer, headers: nil)` parses a complete streamed response body into the same `Response` that the non-streaming endpoint would have produced. Every format is supported: Server-Sent Events for Open Responses, Chat Completions, Messages, and Gemini (`alt=sse`), and AWS event-stream frames for Converse. The stream is first rebuilt into the non-streaming response body, so the existing response parsing applies unchanged. `Serializers::<Format>.collapse_stream(body)` returns that rebuilt hash. `Serializers::<Format>.stream_accumulator` returns an incremental accumulator that takes raw chunks split at any point with `<<` and keeps only reassembly state, not the raw body. In-stream error events raise `ErrorResponseError`. A stream that ends before its terminal event raises the new `IncompleteStreamError` (a subclass of `UnexpectedPayloadError`). An event-stream CRC mismatch raises `ParseError`. A JSON body that is not a stream (e.g. a 4xx error envelope) is passed through so it parses as it would without streaming.
12
+ - `Session#think` can be called with no depth control (`session.think`), leaving whether and how much to reason to the target API's defaults. The Messages serializer maps it to adaptive thinking; other serializers send no depth control. Previously it raised an `ArgumentError`.
13
+ - `Session#think` accepts any `effort` level instead of raising an `ArgumentError` for levels outside `minimal`, `low`, `medium`, `high`, `xhigh`, and `max`, since providers add new levels with new models (e.g. OpenAI's `none`). The level is forwarded to the API, which rejects levels the model doesn't support.
14
+ - `Session#think(false)` accepts an `effort` level, storing `{"type" => "disabled", "effort" => ...}`. The Messages serializer sends it as `output_config.effort` alongside `thinking.type: "disabled"`, since effort also limits token spend without thinking; serializers whose only reasoning control is effort ignore it and keep reasoning off. Previously it raised an `ArgumentError`, which `think(false, budget_tokens:)` still does.
15
+ - The Messages serializer supports `reasoning.type: "between_tools"`, the lowest thinking setting on models that offer it. It was previously dropped silently, which sent the request with thinking left to the model — the opposite of what the caller asked for. It cannot be combined with `budget_tokens` or `display`.
16
+ - The Converse serializer maps `session.reasoning` (including `Session#think`) into `additionalModelRequestFields` for Anthropic and OpenAI models, which read their native reasoning fields from it: the Messages API `thinking` and `output_config.effort` for Anthropic models, the `reasoning` object for OpenAI models, and the flat `reasoning_effort` field for the open-weight gpt-oss models. The model family is detected from the model id, allowing cross-region inference prefixes and ARNs. Values are forwarded without validation, and the fields are deep-merged into the `additional_model_request_fields` extra, whose values win on conflicts; a `thinking` object in the extra replaces the inferred one as a whole.
17
+
18
+ ### Changed
19
+
20
+ - The Chat Completions serializer sends `stream_options: {"include_usage": true}` when `stream` is true and `stream_options` is not set, since streamed responses otherwise report no token usage. Set `stream_options` to opt out (e.g. `{"include_usage" => false}`).
21
+ - `Session#think(false)` turns reasoning off instead of clearing the reasoning configuration. It stores `{"type" => "disabled"}`, which the serializers map to `thinking.type: "disabled"` (Messages, and Anthropic models on Converse), effort `none` (`reasoning_effort` on Chat Completions and gpt-oss models on Converse, `reasoning.effort` on Open Responses and other OpenAI models on Converse), and `thinkingConfig.thinkingBudget: 0` (Gemini). Converse sends nothing for it on other model families rather than raising. Previously `think(false)` set `reasoning` to `nil`, which sends no reasoning parameter and leaves the API's default in place, so models that reason by default (such as Claude models that run adaptive thinking when `thinking` is omitted) kept reasoning. Set `session.reasoning = nil` to get that behavior. The off values are forwarded without checking the model, so models that can't turn reasoning off, or don't accept the parameter, reject the request.
22
+ - The Converse serializer raises an `UnsupportedFormatError` when a non-empty `session.reasoning` is set for a model other than an Anthropic or OpenAI model, instead of warning and omitting it. Set the model's native reasoning fields through the `additional_model_request_fields` extra instead.
23
+ - The Messages serializer now emits a `thinking` parameter for any reasoning configuration that asks for thinking, where it previously only emitted one for `budget_tokens` or an explicit `reasoning.type`. Omitting `thinking` leaves the thinking mode to the model rather than turning thinking on, so `think(effort:)` produced no thinking on models that don't think by default. `thinking.type` defaults to `enabled` when `budget_tokens` is set, so `think(budget_tokens:)` serializes as before, and to `adaptive` for an `effort` level, `display`, or bare `think`. Adaptive thinking is not inferred from `effort` alongside `temperature`, a `top_p` below 0.95, or a forced `tool_choice`, so those sessions still send the effort on its own. Configurations without thinking keys (e.g. only another provider's `summary`) still emit no `thinking`.
24
+ - The Messages serializer forwards any `reasoning.type` to `thinking.type` instead of silently omitting the `thinking` parameter for types outside a fixed list, for the same reason. Which types a model accepts varies by model and changes often, so the API is left to reject types the model doesn't support.
25
+ - The Messages and Gemini serializers forward every `reasoning.effort` level to the API as is (Gemini upcases it) instead of dropping levels outside a fixed list. Which levels a model accepts varies by model and changes often, so the API is left to reject levels the model doesn't support. Previously, levels such as `minimal` on Messages or `xhigh` and `max` on Gemini were dropped silently, leaving the depth at the model's default.
26
+ - The Gemini serializer raises when `reasoning.budget_tokens` and `reasoning.effort` are both set, since the Gemini API rejects `thinkingBudget` and `thinkingLevel` in the same request.
27
+
28
+ ### Removed
29
+
30
+ - `PromptBuilder::Serializers::Converse::Request.reset_reasoning_warning!`, since the Converse serializer no longer warns about reasoning.
31
+
32
+ ### Fixed
33
+
34
+ - The Converse serializer replays `Reasoning` items as `reasoningContent` blocks instead of dropping them, so tool loops with thinking enabled work on Anthropic models, which reject a `toolUse` turn without its signed reasoning. Signed `thinking` blocks are sent as `reasoningText` with their `signature` and `redacted_thinking` blocks as `redactedContent`; unsigned reasoning and summaries are still dropped.
35
+ - The Open Responses serializer no longer forwards the `budget_tokens`, `type`, and `display` reasoning keys, which are not part of the Open Responses schema and are rejected by the API. `think(budget_tokens:)` previously produced a payload the API rejects.
36
+
7
37
  ## 0.4.0
8
38
 
9
39
  ### Added
data/README.md CHANGED
@@ -203,6 +203,46 @@ response.text # => "Authentication failed."
203
203
  response.completed? # => true
204
204
  ```
205
205
 
206
+ ### Parsing Streamed Responses
207
+
208
+ Streaming (`stream: true`, or a streaming endpoint) keeps bytes flowing during long generations. If you only need the final response, `Response.parse_stream` turns a complete streamed body into the same `Response` that the non-streaming endpoint would have produced. It first rebuilds the non-streaming response body from the stream, then parses it with `Response.parse`, so content, tool calls, reasoning, usage, and stop reasons come out exactly as they do for a non-streamed request:
209
+
210
+ ```ruby
211
+ session.stream = true
212
+ # ... send the request and read the whole streamed body ...
213
+ response = PromptBuilder::Response.parse_stream(response_body, :messages)
214
+ ```
215
+
216
+ The body is the raw stream: Server-Sent Events text for Open Responses, Chat Completions, Messages, and Gemini (`streamGenerateContent?alt=sse`), or AWS event-stream bytes for Converse (`ConverseStream`). Gemini and Converse choose streaming by endpoint rather than a request body field. Pass `headers:` the same way you would to `Response.parse`, for example to get the Converse response id from the `x-amzn-requestid` header.
217
+
218
+ To reassemble the stream as it arrives without keeping the raw body, feed chunks to an accumulator. Chunks can split the stream anywhere, including in the middle of an event, a UTF-8 character, or a binary frame. The accumulator keeps only what it needs to rebuild the response:
219
+
220
+ ```ruby
221
+ accumulator = PromptBuilder::Serializers::Messages.stream_accumulator
222
+ http.request(request) do |http_response|
223
+ http_response.read_body { |chunk| accumulator << chunk }
224
+ end
225
+ response = PromptBuilder::Response.parse(accumulator.result, :messages)
226
+ ```
227
+
228
+ `Serializers::<Format>.collapse_stream(body)` returns the rebuilt response hash directly. The body can be a String or an enumerable of chunks.
229
+
230
+ How each format is rebuilt:
231
+
232
+ - **Open Responses**: the full response object is taken from the terminal `response.completed`, `response.incomplete`, or `response.failed` event.
233
+ - **Chat Completions**: `choices[].delta` objects are merged by choice index, and `tool_calls` fragments by tool call index (`function.arguments` is concatenated). Usage comes from the final chunk, which the API only sends when `stream_options.include_usage` is set. The Chat Completions serializer sets it by default when `stream` is true (see [Session Fields](#session-fields)).
234
+ - **Messages**: content blocks are rebuilt by index from the text, citation, thinking, signature, and tool input deltas. `message_delta` supplies the stop reason, and its cumulative usage is merged into the `message_start` usage.
235
+ - **Gemini**: candidate parts from each chunk are concatenated. A text part that starts a chunk continues the text part that ended the previous chunk if both are the same kind (thought or not), keeping the `thoughtSignature`. Parts within one chunk stay separate. A part that carries only a `thoughtSignature` is attached to the text part before it. The `logprobsResult` token lists are concatenated, and `logProbabilitySum` is recomputed from the chosen tokens. `finishReason`, `usageMetadata`, and other fields come from the last chunk that has them. A JSON array body (`streamGenerateContent` without `alt=sse`) is also accepted, but it is buffered whole.
236
+ - **Converse**: event-stream frames are decoded with both CRC32 checksums verified, then content blocks are rebuilt from the `contentBlockStart` and `contentBlockDelta` events (text, tool use input, reasoning text, signatures, and redacted reasoning). `stopReason` comes from `messageStop`, and `usage` and `metrics` come from `metadata`.
237
+
238
+ Errors:
239
+
240
+ - An error event sent mid-stream (with HTTP 200) raises a `PromptBuilder::ErrorResponseError` that includes the error the API reported. Examples are a Messages `error` event such as `overloaded_error`, an Open Responses `error` event, an `error` chunk from Chat Completions or Gemini, and a Converse `exception` frame such as `throttlingException`.
241
+ - A stream that ends before the format's terminal event raises a `PromptBuilder::IncompleteStreamError`, a subclass of `UnexpectedPayloadError`, so a truncated stream is never treated as a complete response. The terminal events are Open Responses `response.completed`/`incomplete`/`failed`, Chat Completions `data: [DONE]`, Messages `message_stop`, a `finishReason` on every Gemini candidate (or a `promptFeedback.blockReason`), and Converse `messageStop`.
242
+ - A malformed stream, such as an event-stream checksum mismatch or invalid JSON in an event, raises a `PromptBuilder::ParseError`.
243
+ - A body that is a JSON object or array, such as an error envelope returned with a 4xx status, is not a stream. It is passed through unchanged, so `Response.parse_stream` raises the same `ErrorResponseError` that `Response.parse` would.
244
+ - Unknown event types are ignored, so new event types the providers add don't break parsing.
245
+
206
246
  ### Structured Output
207
247
 
208
248
  Use `json_output` to request JSON Schema structured output and `parsed_json` to read it back:
@@ -229,16 +269,22 @@ data = response.parsed_json! # raises PromptBuilder::ParseError (including the
229
269
  Use `think` to configure reasoning portably; each serializer maps it to its native parameter:
230
270
 
231
271
  ```ruby
272
+ session.think # API defaults (adaptive thinking on Messages)
232
273
  session.think(effort: :medium) # portable across serializers
233
274
  session.think(budget_tokens: 8_000) # explicit token budget where supported
234
- session.think(false) # clear the reasoning configuration
275
+ session.think(false) # turn reasoning off
276
+ session.think(false, effort: :low) # off, with an effort level for Messages
277
+ session.reasoning = nil # no reasoning parameter; the API default applies
235
278
  ```
236
279
 
237
- - `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.
238
- - `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`.
239
- - The Converse API has no reasoning parameter; the serializer warns once per process and omits the configuration.
280
+ - `effort:` (any level, such as `minimal`, `low`, `medium`, `high`, `xhigh`, or `max`; `think` doesn't restrict it, since providers add new levels with new models) maps to `output_config.effort` (Messages), `reasoning_effort` (Chat Completions), `thinkingConfig.thinkingLevel` (Gemini), and `reasoning.effort` (Open Responses). Every serializer forwards the level as is (Gemini upcases it) without checking it against the model. Which levels a model accepts varies and changes often, so the target API is left to reject levels the model doesn't support.
281
+ - `budget_tokens:` maps to `thinking.budget_tokens` (Messages) and `thinkingConfig.thinkingBudget` (Gemini); effort-based APIs ignore it (Open Responses omits it from `reasoning`, along with the Anthropic-only `type` and `display` keys). The Gemini serializer raises an `UnsupportedFormatError` when `budget_tokens` and `effort` would both be sent, since the Gemini API rejects `thinkingBudget` and `thinkingLevel` together. A fixed thinking budget is only accepted by some Anthropic models — `effort:` is the portable control. 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`.
282
+ - Calling `think` with no depth control leaves both whether and how much to reason to the target API's defaults. Only the Messages serializer has a switch for it, which it maps to adaptive thinking (`thinking.type: "adaptive"`); Chat Completions and Gemini send no reasoning parameter and Open Responses sends an empty `reasoning` object, so models that don't reason by default won't reason.
283
+ - `think(false)` turns reasoning off by storing `{"type" => "disabled"}`, which each serializer maps to its API's off switch: `thinking.type: "disabled"` (Messages), `reasoning_effort: "none"` (Chat Completions), `reasoning.effort: "none"` (Open Responses), and `thinkingConfig.thinkingBudget: 0` (Gemini). As with effort levels, the value is forwarded without checking the model, and models that can't turn reasoning off (or don't accept the parameter at all) reject it. This is different from `session.reasoning = nil`, which sends no reasoning parameter and leaves the API's default in place, so models that reason by default (such as Claude models that run adaptive thinking when `thinking` is omitted, or OpenAI models whose default effort isn't `none`) keep reasoning.
284
+ - `think(false, effort:)` stores `{"type" => "disabled", "effort" => ...}`. On the Messages API, effort also limits overall token spend without thinking, so the serializer sends it as `output_config.effort` next to `thinking.type: "disabled"`. Chat Completions, Open Responses, and Gemini have no effort control separate from reasoning, so they ignore the effort and send their off value, keeping reasoning off. `think(false)` still raises an `ArgumentError` with `budget_tokens:`.
285
+ - The Converse API has no reasoning parameter of its own, so the serializer sends the model family's native fields in `additionalModelRequestFields`: the Messages API `thinking` and `output_config.effort` fields for Anthropic models, the Responses API `reasoning` object (e.g. `reasoning.effort`) for OpenAI models, and the flat `reasoning_effort` field for OpenAI gpt-oss models. `think(false)` maps to `thinking.type: "disabled"` for Anthropic models and effort `none` for OpenAI models. Any other model raises an `UnsupportedFormatError` when `reasoning` holds a depth control or other setting (a bare `think` or `think(false)` sends nothing); pass that model's native fields through the `additional_model_request_fields` extra instead. See note ¹⁵.
240
286
 
241
- Direct `session.reasoning = {...}` assignment keeps working for provider-specific keys (e.g. Anthropic's `display`, Gemini's `summary`).
287
+ Direct `session.reasoning = {...}` assignment keeps working for provider-specific keys (e.g. Anthropic's `type` and `display`, Gemini's `summary`).
242
288
 
243
289
  ### Agentic Tool Loops
244
290
 
@@ -639,12 +685,12 @@ The following table shows which session configuration fields are supported by ea
639
685
  | `presence_penalty` | ✅ | ❌ | ✅ → `generationConfig.presencePenalty` | ❌ |
640
686
  | `prompt_cache_key` | ✅ | ❌ | ❌ | ❌ |
641
687
  | `prompt_cache_retention` | ✅ | ❌ | ❌ | ❌ |
642
- | `reasoning` | `effort` key only | `budget_tokens`/`display`/`effort`/`type` only | `budget_tokens`/`effort`/`summary: "auto"` only | ❌ |
688
+ | `reasoning` | `effort` key only | `budget_tokens`/`display`/`effort`/`type` only¹⁴ | `budget_tokens`/`effort`/`summary: "auto"` only | Anthropic and OpenAI models only → `additionalModelRequestFields`¹⁵ |
643
689
  | `safety_identifier` | ✅ | ✅ → `metadata.user_id` | ❌ | ❌ |
644
690
  | `service_tier` | ✅ | `auto`/`standard_only` only | `unspecified`/`standard`/`flex`/`priority` only | ✅ → `serviceTier.type` |
645
691
  | `store` | ✅ | ❌ | ✅ | ❌ |
646
- | `stream` | ✅ | ✅ | endpoint-selected⁸ | ❌ |
647
- | `stream_options` | `include_usage`/`include_obfuscation` only | ❌ | ❌ | ❌ |
692
+ | `stream` | ✅ | ✅ | endpoint-selected⁸ | endpoint-selected⁸ |
693
+ | `stream_options` | `include_usage`/`include_obfuscation` only¹⁶ | ❌ | ❌ | ❌ |
648
694
  | `text` | `format`/`verbosity` only | `format.type=json_schema` only | `format` key only | `format.type == json_schema` only |
649
695
  | `top_logprobs` | ✅ | ❌ | ✅ → `responseLogprobs`/`logprobs` | ❌ |
650
696
  | `truncation` | ❌ | ❌ | ❌ | ❌ |
@@ -659,24 +705,30 @@ The following table shows which session configuration fields are supported by ea
659
705
  | `InputVideo` | ❌ | ❌ | URL required (Google-hosted URI only)⁶ | S3 URI only |
660
706
  | `OutputText` | ✅ (annotations dropped on request)¹¹ | ✅ (annotations ↔ citations)¹² | ✅ | ✅ |
661
707
  | `RefusalContent` | dropped⁹ | dropped⁹ | dropped⁹ | dropped⁹ |
662
- | `Reasoning` items | ❌ | ✅³ | ✅⁴ | ❌ |
708
+ | `Reasoning` items | ❌ | ✅³ | ✅⁴ | signed only → `reasoningContent`³ |
663
709
  | `Compaction` items | ❌ | ❌ | ❌ | ❌ |
664
710
  | `ItemReference` items | ❌ | ❌ | ❌ | ❌ |
665
711
 
666
712
  ¹ Messages format uses the media type from the data URL for base64 sources; set `InputFile.media_type` to override. `file_id` is mapped to a `file` source for the Anthropic Files API beta — set the appropriate `anthropic-beta: files-api-2025-04-14` header in your HTTP client.
667
713
  ² Converse format infers the document type from the filename or URL extension.
668
- ³ Messages format only emits `thinking` blocks that include a cryptographic `signature`; unsigned blocks are silently dropped.
714
+ ³ Messages and Converse formats only emit `thinking` blocks that include a cryptographic `signature` (Converse sends them as `reasoningContent.reasoningText`, and `redacted_thinking` as `reasoningContent.redactedContent`); unsigned blocks are silently dropped.
669
715
  ⁴ Gemini format passes `thoughtSignature` through transparently on reasoning, text, and function-call parts. `redacted_thinking` blocks are silently skipped.
670
716
  ⁵ Anthropic does not support a `detail` field on images; it is silently dropped. `file_id` is mapped to a `file` source (Anthropic Files API beta).
671
717
  ⁶ Gemini supports inline base64 data or any URL. For files, set `media_type` or use a recognized `filename`/URL extension.
672
718
  ⁷ Chat Completions does not accept `InputImage.file_id` — the `image_file` content type is Assistants API only. Use a URL or base64 `data` instead; a `file_id`-only image is omitted.
673
- ⁸ Gemini selects streaming by endpoint (`:streamGenerateContent`), not a request body field, so `session.stream` is silently ignored when serializing to Gemini.
719
+ ⁸ Gemini (`:streamGenerateContent`) and Converse (`ConverseStream`) select streaming by endpoint, not a request body field, so `session.stream` is silently ignored when serializing to them. `Response.parse_stream` parses their streamed responses (see [Parsing Streamed Responses](#parsing-streamed-responses)).
674
720
  ⁹ `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.
675
721
  ¹⁰ 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.
676
722
  ¹¹ `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.
677
723
  ¹² 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.
678
724
  ¹³ Converse `requestMetadata` only accepts string key/value pairs. This serializer stringifies scalar metadata values and silently omits nested arrays or objects.
679
725
 
726
+ ¹⁴ A Messages `reasoning` configuration that asks for thinking (`type`, `budget_tokens`, `display`, an `effort` level, or an empty configuration from a bare `think`) emits a `thinking` parameter, since omitting it leaves the thinking mode to the model rather than turning thinking on. Adaptive thinking is not inferred from `effort` alongside `temperature`, a `top_p` below 0.95, or a forced `tool_choice`; the effort is sent on its own. `reasoning.type` is forwarded as is, including types the serializer doesn't know, and the API rejects types the model doesn't support.
727
+
728
+ ¹⁵ The Converse serializer detects the model family from the model id, allowing a cross-region inference prefix (`us.`, `global.`, ...) and an ARN resource path. Anthropic models (`anthropic.*`) get `thinking` (`type`, `budget_tokens`, `display`) and `output_config.effort`, with `thinking.type` inferred as in the Messages serializer (note ¹⁴) but without its validation. OpenAI models (`openai.*`) get `reasoning` without the Anthropic-only `budget_tokens`, `type`, and `display` keys; a bare `think` or `budget_tokens` alone sends nothing. The open-weight gpt-oss models (`openai.gpt-oss-*`) read only a flat `reasoning_effort` field, so they get `effort` as `reasoning_effort` and nothing else. A disabled configuration (`think(false)`) sends effort `none` to both OpenAI families in place of any effort; gpt-oss models can't turn reasoning off and reject it. Models in other families are assumed not to reason without their native fields, so a disabled configuration sends nothing for them. Application inference profile ARNs don't name a model family and raise like other unsupported models. Values are forwarded without validation, and the fields are deep-merged into the `additional_model_request_fields` extra, whose values win on conflicts. A `thinking` object in the extra replaces the inferred one as a whole rather than being merged into it, since which `thinking` keys are valid depends on its `type`.
729
+
730
+ ¹⁶ A streamed Chat Completions response only reports token usage when `stream_options.include_usage` is set, so the serializer sends `stream_options: {"include_usage": true}` whenever `stream` is true and `stream_options` is not set. To opt out, set `stream_options` yourself, for example `{"include_usage" => false}`.
731
+
680
732
  ### Features Not Accessible Through Open Responses
681
733
 
682
734
  These target API features are not available through the Open Responses canonical format because Open Responses does not expose the equivalent parameters:
@@ -753,6 +805,7 @@ Request-side mappings worth calling out:
753
805
  | `text.format` | `response_format` (the OR-canonical flat `json_schema` is reshaped into `{"type": "json_schema", "json_schema": {...}}`) |
754
806
  | `text.verbosity` | top-level `verbosity` |
755
807
  | `reasoning.effort` | `reasoning_effort` |
808
+ | `reasoning.type: "disabled"` | `reasoning_effort: "none"` (replaces any effort) |
756
809
  | `tool_choice: {"type": "function", "name": ...}` | `{"type": "function", "function": {"name": ...}}` |
757
810
  | `InputFile.file_id` | `{"type": "file", "file": {"file_id": ...}}` |
758
811
  | `InputFile` with data URL (+ optional `filename`/`media_type`) | `{"type": "file", "file": {"filename": ..., "file_data": "data:<media_type>;base64,..."}}` |
@@ -763,7 +816,7 @@ Chat Completions request features with no canonical Open Responses field are ava
763
816
  Response-side behavior and limitations:
764
817
 
765
818
  - Responses with multiple choices (`n > 1`) parse only the first choice; additional candidates are dropped.
766
- - Streaming chunks (`chat.completion.chunk` deltas) raise `UnsupportedFormatError` — this gem expects a fully assembled non-streaming response body.
819
+ - `Response.parse` raises `UnsupportedFormatError` for a single streaming chunk (a `chat.completion.chunk` delta). Use `Response.parse_stream` to parse a whole streamed body (see [Parsing Streamed Responses](#parsing-streamed-responses)).
767
820
  - `system_fingerprint` is exposed on `response.provider_data`.
768
821
  - `service_tier` is populated when present on the response.
769
822
  - `message.annotations` (URL citations from `web_search_options`) are copied onto `OutputText.annotations`.
@@ -774,6 +827,8 @@ Response-side behavior and limitations:
774
827
 
775
828
  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`.
776
829
 
830
+ When thinking is on (any `thinking.type` other than `disabled`), the serializer raises an `UnsupportedFormatError` if `temperature` is set, `top_p` is below `0.95`, `tool_choice` forces tool use, or `max_output_tokens` is not greater than `reasoning.budget_tokens`. Beyond that, which values the Messages API accepts for `temperature`, `top_p`, `reasoning.type`, and forced `tool_choice` depends on the model, and the serialized payload carries no model capability information, so these are forwarded without further validation.
831
+
777
832
  A few features map between the canonical Open Responses format and the Messages API in non-obvious ways:
778
833
 
779
834
  | Canonical field / value | Messages mapping |
@@ -783,9 +838,10 @@ A few features map between the canonical Open Responses format and the Messages
783
838
  | `FunctionCallOutput.status` ∈ `incomplete`, `failed`, `error` | `tool_result.is_error: true` |
784
839
  | `safety_identifier` | `metadata.user_id` |
785
840
  | `parallel_tool_calls: false` | `tool_choice.disable_parallel_tool_use: true` (forces `tool_choice.type` to `auto` if unset) |
841
+ | `reasoning` asking for thinking | `thinking` (`thinking.type` defaults to `adaptive`; see note ¹⁴) |
786
842
  | `reasoning.budget_tokens` | `thinking.budget_tokens` (with `thinking.type` defaulted to `enabled`) |
787
- | `reasoning.effort` | `output_config.effort` (`low`, `medium`, `high`, `xhigh`, or `max`) |
788
- | `reasoning.type: "adaptive"` | `thinking.type: "adaptive"` |
843
+ | `reasoning.effort` | `output_config.effort` (forwarded as is) |
844
+ | `reasoning.type` | `thinking.type` (forwarded as is, e.g. `adaptive`, `between_tools`, `disabled`, or `enabled`) |
789
845
  | `text.format.type: "json_schema"` | `output_config.format` (`type` and `schema` only; `name`, `description`, and `strict` are ignored) |
790
846
  | `Tools::Definition#strict` | tool definition `strict: true` |
791
847
  | `tool_choice: "required"` | `{"type": "any"}` |
@@ -811,7 +867,9 @@ Request-side mappings worth calling out:
811
867
  | `text.format == "text"` | `generationConfig.responseMimeType = "text/plain"` |
812
868
  | `text.format == "json_object"` | `generationConfig.responseMimeType = "application/json"` |
813
869
  | `text.format == "json_schema"` | `generationConfig.responseMimeType = "application/json"` + `responseSchema` (read from `format.json_schema.schema` or `format.schema`) |
814
- | `reasoning.budget_tokens` | `generationConfig.thinkingConfig.thinkingBudget` |
870
+ | `reasoning.budget_tokens` | `generationConfig.thinkingConfig.thinkingBudget` (raises alongside a mapped `reasoning.effort`) |
871
+ | `reasoning.type: "disabled"` | `generationConfig.thinkingConfig.thinkingBudget: 0` (replaces any budget or effort) |
872
+ | `reasoning.effort` | `generationConfig.thinkingConfig.thinkingLevel` (upcased and forwarded as is) |
815
873
  | `tool_choice: "auto"` / `"none"` / `"required"` | `toolConfig.functionCallingConfig.mode = AUTO` / `NONE` / `ANY` |
816
874
  | `tool_choice: {"type": "function", "name": ...}` | `mode = ANY` + `allowedFunctionNames: [name]` |
817
875
  | Tool definitions | single `tools[0].functionDeclarations[]` entry |
@@ -838,7 +896,7 @@ Response-side limitations:
838
896
  - `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.
839
897
  - An empty `candidates` array combined with `promptFeedback.blockReason` is mapped to `failed`.
840
898
  - `usageMetadata.cachedContentTokenCount`, `toolUsePromptTokenCount`, `promptTokensDetails`, `cacheTokensDetails`, and `toolUsePromptTokensDetails` populate `response.usage.input_tokens_details`. `thoughtsTokenCount` and `candidatesTokensDetails` populate `response.usage.output_tokens_details`.
841
- - Response metadata with no canonical Open Responses slot is exposed on `response.provider_data`: `groundingMetadata`, `citationMetadata`, `urlContextMetadata`, `urlRetrievalMetadata`, `safetyRatings`, `groundingAttributions`, `avgLogprobs`, `logprobsResult`, `finishMessage`, candidate `index`, top-level `createTime`, and full `promptFeedback`. Streaming chunks are not parsed — this gem expects a fully assembled non-streaming response body.
899
+ - Response metadata with no canonical Open Responses slot is exposed on `response.provider_data`: `groundingMetadata`, `citationMetadata`, `urlContextMetadata`, `urlRetrievalMetadata`, `safetyRatings`, `groundingAttributions`, `avgLogprobs`, `logprobsResult`, `finishMessage`, candidate `index`, top-level `createTime`, and full `promptFeedback`. Use `Response.parse_stream` to parse a `streamGenerateContent` response (see [Parsing Streamed Responses](#parsing-streamed-responses)).
842
900
 
843
901
  ### Converse-specific notes
844
902
 
@@ -857,6 +915,10 @@ Request-side restrictions worth calling out:
857
915
  | `tool_choice: "auto"` | `{"auto": {}}` |
858
916
  | `tool_choice: "required"` | `{"any": {}}` |
859
917
  | `tool_choice: {"type": "function", "name": ...}` | `{"tool": {"name": ...}}` |
918
+ | `reasoning` (Anthropic models) | `additionalModelRequestFields.thinking` / `additionalModelRequestFields.output_config.effort` |
919
+ | `reasoning` (OpenAI models) | `additionalModelRequestFields.reasoning` |
920
+ | `reasoning` (OpenAI gpt-oss models) | `additionalModelRequestFields.reasoning_effort` (`effort` only) |
921
+ | `reasoning` (other models) | raises `UnsupportedFormatError` (nothing is sent for a bare `think` or `think(false)`) |
860
922
 
861
923
  Content and message restrictions:
862
924
 
@@ -867,7 +929,7 @@ Content and message restrictions:
867
929
  - `InputFile.file_id` is silently ignored because Converse does not accept provider file IDs for documents.
868
930
  - `InputVideo` requires an `s3://` URI; a non-S3 URL is omitted, and raw bytes (`source.bytes`) cannot be sent because the canonical `InputVideo` content type does not model byte data.
869
931
  - `InputImage`, `InputFile`, and `InputVideo` are only supported in user messages; the same content on an assistant message is omitted. `RefusalContent`, `OutputText.annotations`, and `OutputText.logprobs` are also dropped because Converse has no request-side representation for them.
870
- - `Reasoning` items are silently skipped on the request side, so multi-turn extended-thinking + tool-use loops are not supported through this serializer (the response parser does decode `reasoningContent` blocks back into `Reasoning` items, but they cannot be sent back).
932
+ - `Reasoning` items are replayed as `reasoningContent` blocks on the assistant turn, so multi-turn extended-thinking + tool-use loops work: Anthropic models require the signed reasoning ahead of a `toolUse` block when thinking is on. Only signed `thinking` blocks and `redacted_thinking` blocks are sent, because Bedrock validates replayed reasoning against its signature; unsigned reasoning and summaries are dropped. Signatures are tied to the model that produced them, so switching a session to a model from another family can make the request fail on the replayed reasoning; start a fresh session or remove the `Reasoning` items when you switch.
871
933
  - `Compaction` and `ItemReference` items are silently skipped.
872
934
  - `FunctionCall.arguments` must parse to a JSON object; non-object JSON values raise (Bedrock's `toolUse.input` requires an object).
873
935
  - `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.
data/VERSION CHANGED
@@ -1 +1 @@
1
- 0.4.0
1
+ 0.5.0
@@ -24,6 +24,14 @@ module PromptBuilder
24
24
  # error envelope). The message contains the error reported by the API.
25
25
  class ErrorResponseError < UnexpectedPayloadError; end
26
26
 
27
- # Raised by Response#parsed_json! when the response text is not valid JSON.
27
+ # Raised when a streamed response ends before the format's terminal event
28
+ # (e.g. a dropped connection), so a truncated stream is never treated as a
29
+ # complete response.
30
+ class IncompleteStreamError < UnexpectedPayloadError; end
31
+
32
+ # Raised when text or binary data cannot be decoded: by Response#parsed_json!
33
+ # when the response text is not valid JSON, and by the stream decoders when a
34
+ # streamed response body is malformed (e.g. an AWS event-stream checksum
35
+ # mismatch or an unparseable event payload).
28
36
  class ParseError < Error; end
29
37
  end
@@ -172,6 +172,30 @@ module PromptBuilder
172
172
  serializer.parse_response(hash, headers: headers)
173
173
  end
174
174
 
175
+ # Parse a complete streamed response body (from a request sent with
176
+ # +stream: true+ or a streaming endpoint) into a Response. The stream is
177
+ # reassembled into the body the non-streaming endpoint would have
178
+ # returned and then parsed with #parse, so the result is the same as for
179
+ # a non-streamed request.
180
+ #
181
+ # @param body [String, #each] the raw streamed body (SSE text, or AWS
182
+ # event-stream bytes for Converse), or an enumerable of body chunks
183
+ # @param serializer_class [Class, Symbol] a serializer class or a symbol
184
+ # shorthand (see #parse)
185
+ # @param headers [Hash, #each, nil] the HTTP response headers (see #parse)
186
+ # @return [Response]
187
+ # @raise [IncompleteStreamError] if the stream ended before the format's
188
+ # terminal event
189
+ # @raise [ErrorResponseError] if the stream contains an error event or
190
+ # the body is an API error envelope
191
+ # @raise [ParseError] if the stream is malformed
192
+ # @example
193
+ # response = PromptBuilder::Response.parse_stream(sse_body, :messages)
194
+ def parse_stream(body, serializer_class, headers: nil)
195
+ serializer = Serializers.resolve(serializer_class)
196
+ parse(serializer.collapse_stream(body), serializer, headers: headers)
197
+ end
198
+
175
199
  # Deserialize a Response from a Hash.
176
200
  #
177
201
  # @param hash [Hash] a Hash with string keys from the API response
@@ -32,6 +32,42 @@ module PromptBuilder
32
32
  deserialize_response(hash)
33
33
  end
34
34
 
35
+ # Create an incremental accumulator that reassembles a streamed
36
+ # response body in this format into the Hash the non-streaming
37
+ # endpoint would have returned. Feed it raw body chunks with +<<+,
38
+ # then call +result+.
39
+ #
40
+ # @return [Streaming::Accumulator] a new accumulator
41
+ # @raise [UnsupportedFormatError] if the format has no stream accumulator
42
+ def stream_accumulator
43
+ unless const_defined?(:StreamAccumulator, false)
44
+ raise UnsupportedFormatError, "#{name} does not support streamed responses"
45
+ end
46
+
47
+ const_get(:StreamAccumulator, false).new
48
+ end
49
+
50
+ # Reassemble a complete streamed response body into the Hash the
51
+ # non-streaming endpoint would have returned, ready for
52
+ # +parse_response+.
53
+ #
54
+ # @param body [String, #each] the raw streamed body (SSE text, or AWS
55
+ # event-stream bytes for Converse), or an enumerable of body chunks
56
+ # @return [Hash] the non-streaming equivalent response
57
+ # @raise [IncompleteStreamError] if the stream ended before the
58
+ # format's terminal event
59
+ # @raise [ErrorResponseError] if the stream contains an error event
60
+ # @raise [ParseError] if the stream is malformed
61
+ def collapse_stream(body)
62
+ accumulator = stream_accumulator
63
+ if body.is_a?(String)
64
+ accumulator << body
65
+ else
66
+ body.each { |chunk| accumulator << chunk }
67
+ end
68
+ accumulator.result
69
+ end
70
+
35
71
  private
36
72
 
37
73
  def serialize_request(_session)
@@ -68,6 +104,16 @@ module PromptBuilder
68
104
  nil
69
105
  end
70
106
 
107
+ # Whether a reasoning configuration turns reasoning off, as stored by
108
+ # Session#think(false). Distinct from a nil configuration, which leaves
109
+ # reasoning to the API's default.
110
+ #
111
+ # @param reasoning [Hash, nil] the session's reasoning configuration
112
+ # @return [Boolean]
113
+ def reasoning_disabled?(reasoning)
114
+ reasoning.is_a?(Hash) && reasoning["type"] == "disabled"
115
+ end
116
+
71
117
  # Ensure a response payload is a Hash containing the key that identifies
72
118
  # it as a well-formed response for this format. Raised before parsing so
73
119
  # malformed bodies (e.g. provider error envelopes) fail loudly with the
@@ -22,9 +22,15 @@ module PromptBuilder
22
22
  # Partially supported session fields (unsupported keys are omitted):
23
23
  # - +text+ — only +format+ (mapped to +response_format+) and +verbosity+
24
24
  # (mapped to top-level +verbosity+) are supported
25
- # - +reasoning+ — only the +effort+ key is mapped to +reasoning_effort+
25
+ # - +reasoning+ — only the +effort+ key is mapped to +reasoning_effort+;
26
+ # +type: "disabled"+ (Session#think(false)) is sent as
27
+ # +reasoning_effort: "none"+ in place of any effort, and models that
28
+ # cannot turn reasoning off reject it
26
29
  # - +stream_options+ — only +include_usage+ and +include_obfuscation+ are
27
- # supported, and only when +stream+ is set (otherwise it is omitted)
30
+ # supported, and only when +stream+ is set (otherwise it is omitted).
31
+ # When +stream+ is set and +stream_options+ is not, +include_usage: true+
32
+ # is sent so the streamed response reports token usage like a
33
+ # non-streamed one
28
34
  #
29
35
  # Input content restrictions:
30
36
  # - +instructions+ is serialized as a system message inserted after the
@@ -97,9 +103,14 @@ module PromptBuilder
97
103
  h["prompt_cache_retention"] = session.prompt_cache_retention if session.prompt_cache_retention
98
104
  h["stream"] = session.stream unless session.stream.nil?
99
105
  # stream_options is only valid alongside stream; otherwise it is omitted.
100
- # Unsupported stream_options keys are dropped.
101
- if session.stream_options && session.stream
102
- stream_options = normalize_hash(session.stream_options).slice(*SUPPORTED_STREAM_OPTION_KEYS)
106
+ # Unsupported stream_options keys are dropped. Streamed responses only
107
+ # report usage with include_usage, so it is requested by default.
108
+ if session.stream
109
+ stream_options = if session.stream_options
110
+ normalize_hash(session.stream_options).slice(*SUPPORTED_STREAM_OPTION_KEYS)
111
+ else
112
+ {"include_usage" => true}
113
+ end
103
114
  h["stream_options"] = stream_options unless stream_options.empty?
104
115
  end
105
116
 
@@ -110,7 +121,10 @@ module PromptBuilder
110
121
  end
111
122
 
112
123
  if session.reasoning
113
- effort = normalize_hash(session.reasoning)["effort"]
124
+ reasoning = normalize_hash(session.reasoning)
125
+ # Effort is the only reasoning control, so turning reasoning off
126
+ # overrides any effort set alongside it.
127
+ effort = reasoning_disabled?(reasoning) ? "none" : reasoning["effort"]
114
128
  h["reasoning_effort"] = effort if effort
115
129
  end
116
130
 
@@ -10,7 +10,9 @@ module PromptBuilder
10
10
  # responses, legacy +function_call+ responses, non-function tool calls,
11
11
  # and unknown content block types are silently skipped. Streaming chunks
12
12
  # (+chat.completion.chunk+ deltas) raise +UnsupportedFormatError+ — this
13
- # parser expects a fully assembled non-streaming response body.
13
+ # parser expects a fully assembled non-streaming response body; use
14
+ # ChatCompletion::StreamAccumulator (or Response.parse_stream) to
15
+ # reassemble a streamed body first.
14
16
  # Top-level +system_fingerprint+ is exposed through +extra+.
15
17
  class Response < Base
16
18
  class << self
@@ -0,0 +1,143 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PromptBuilder
4
+ module Serializers
5
+ class ChatCompletion < Base
6
+ # Reassembles an OpenAI Chat Completions SSE stream of
7
+ # +chat.completion.chunk+ objects into the +chat.completion+ object the
8
+ # non-streaming endpoint would have returned.
9
+ #
10
+ # Choice deltas are merged by +choices[].index+ into a +message+:
11
+ # +content+, +refusal+, and other string fields are concatenated, and
12
+ # +tool_calls+ fragments are merged by their own +index+, with +id+,
13
+ # +type+, and +function.name+ taken as they appear and
14
+ # +function.arguments+ concatenated. +finish_reason+ comes from the chunk
15
+ # that carries it, and +logprobs+ arrays are concatenated. Usage arrives
16
+ # in a final chunk with empty +choices+, and only when the request set
17
+ # +stream_options.include_usage+.
18
+ #
19
+ # The stream must end with +data: [DONE]+; a chunk with an +error+ raises
20
+ # an ErrorResponseError.
21
+ class StreamAccumulator < Streaming::Accumulator
22
+ DONE = "[DONE]"
23
+ private_constant :DONE
24
+
25
+ def initialize
26
+ super
27
+ @response = {}
28
+ @choices = {}
29
+ @done = false
30
+ end
31
+
32
+ private
33
+
34
+ def handle_event(event)
35
+ if event.data.strip == DONE
36
+ @done = true
37
+ return
38
+ end
39
+
40
+ chunk = parse_json(event.data)
41
+ return unless chunk.is_a?(Hash)
42
+
43
+ if chunk["error"]
44
+ error = chunk["error"]
45
+ message = error.is_a?(Hash) ? [error["code"] || error["type"], error["message"]].compact.join(": ") : error.to_s
46
+ raise_error_event!(message)
47
+ end
48
+
49
+ chunk.each do |key, value|
50
+ next if value.nil?
51
+
52
+ if key == "choices"
53
+ value.each_with_index { |choice, position| merge_choice(choice, position) if choice.is_a?(Hash) }
54
+ else
55
+ @response[key] = value
56
+ end
57
+ end
58
+ end
59
+
60
+ def merge_choice(choice, position)
61
+ index = choice["index"] || position
62
+ merged = @choices[index] ||= {"index" => index, "message" => {"role" => "assistant", "content" => nil}, "tool_calls" => {}}
63
+
64
+ merge_delta(merged, choice["delta"]) if choice["delta"].is_a?(Hash)
65
+ merge_logprobs(merged, choice["logprobs"]) if choice["logprobs"].is_a?(Hash)
66
+ merged["finish_reason"] = choice["finish_reason"] if choice["finish_reason"]
67
+ end
68
+
69
+ def merge_delta(merged, delta)
70
+ message = merged["message"]
71
+ delta.each do |key, value|
72
+ next if value.nil?
73
+
74
+ case key
75
+ when "role"
76
+ message["role"] = value
77
+ when "tool_calls"
78
+ value.each_with_index { |call, position| merge_tool_call(merged["tool_calls"], call, position) if call.is_a?(Hash) }
79
+ when "annotations"
80
+ (message["annotations"] ||= []).concat(value) if value.is_a?(Array)
81
+ else
82
+ if value.is_a?(String)
83
+ message[key] = message[key] ? +message[key] : +""
84
+ message[key] << value
85
+ else
86
+ message[key] = value
87
+ end
88
+ end
89
+ end
90
+ end
91
+
92
+ def merge_tool_call(tool_calls, delta, position)
93
+ call = tool_calls[delta["index"] || position] ||= {"function" => {}}
94
+ call["id"] = delta["id"] if delta["id"]
95
+ call["type"] = delta["type"] if delta["type"]
96
+
97
+ function = delta["function"]
98
+ return unless function.is_a?(Hash)
99
+
100
+ call["function"]["name"] = function["name"] if function["name"]
101
+ if function["arguments"]
102
+ arguments = call["function"]["arguments"]
103
+ call["function"]["arguments"] = arguments ? +arguments : +""
104
+ call["function"]["arguments"] << function["arguments"]
105
+ end
106
+ end
107
+
108
+ def merge_logprobs(merged, logprobs)
109
+ target = merged["logprobs"] ||= {}
110
+ logprobs.each do |key, value|
111
+ if value.is_a?(Array)
112
+ (target[key] ||= []).concat(value)
113
+ elsif !value.nil?
114
+ target[key] = value
115
+ end
116
+ end
117
+ end
118
+
119
+ def build_result
120
+ incomplete!("Chat Completions stream ended without data: [DONE]") unless @done
121
+
122
+ choices = @choices.sort_by { |index, _| index.to_i }.map { |_, choice| build_choice(choice) }
123
+ @response.merge("object" => "chat.completion", "choices" => choices)
124
+ end
125
+
126
+ def build_choice(choice)
127
+ message = choice["message"]
128
+ tool_calls = choice["tool_calls"].sort_by { |index, _| index.to_i }.map do |_, call|
129
+ {"type" => "function"}.merge(call)
130
+ end
131
+ message = message.merge("tool_calls" => tool_calls) unless tool_calls.empty?
132
+
133
+ {
134
+ "index" => choice["index"],
135
+ "message" => message,
136
+ "logprobs" => choice["logprobs"],
137
+ "finish_reason" => choice["finish_reason"]
138
+ }
139
+ end
140
+ end
141
+ end
142
+ end
143
+ end
@@ -7,6 +7,7 @@ module PromptBuilder
7
7
  class ChatCompletion < Base
8
8
  autoload :Request, File.expand_path("chat_completion/request", __dir__)
9
9
  autoload :Response, File.expand_path("chat_completion/response", __dir__)
10
+ autoload :StreamAccumulator, File.expand_path("chat_completion/stream_accumulator", __dir__)
10
11
 
11
12
  class << self
12
13
  # Export a session to Chat Completions request payload.