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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +30 -0
- data/README.md +79 -17
- data/VERSION +1 -1
- data/lib/prompt_builder/errors.rb +9 -1
- data/lib/prompt_builder/response.rb +24 -0
- data/lib/prompt_builder/serializers/base.rb +46 -0
- data/lib/prompt_builder/serializers/chat_completion/request.rb +20 -6
- data/lib/prompt_builder/serializers/chat_completion/response.rb +3 -1
- data/lib/prompt_builder/serializers/chat_completion/stream_accumulator.rb +143 -0
- data/lib/prompt_builder/serializers/chat_completion.rb +1 -0
- data/lib/prompt_builder/serializers/converse/request.rb +195 -23
- data/lib/prompt_builder/serializers/converse/stream_accumulator.rb +151 -0
- data/lib/prompt_builder/serializers/converse.rb +1 -0
- data/lib/prompt_builder/serializers/gemini/request.rb +26 -8
- data/lib/prompt_builder/serializers/gemini/stream_accumulator.rb +188 -0
- data/lib/prompt_builder/serializers/gemini.rb +1 -0
- data/lib/prompt_builder/serializers/messages/request.rb +95 -33
- data/lib/prompt_builder/serializers/messages/stream_accumulator.rb +110 -0
- data/lib/prompt_builder/serializers/messages.rb +1 -0
- data/lib/prompt_builder/serializers/open_responses/request.rb +25 -0
- data/lib/prompt_builder/serializers/open_responses/stream_accumulator.rb +55 -0
- data/lib/prompt_builder/serializers/open_responses.rb +1 -0
- data/lib/prompt_builder/session.rb +49 -31
- data/lib/prompt_builder/streaming/accumulator.rb +169 -0
- data/lib/prompt_builder/streaming/event_stream_decoder.rb +137 -0
- data/lib/prompt_builder/streaming/sse_parser.rb +152 -0
- data/lib/prompt_builder/streaming.rb +14 -0
- data/lib/prompt_builder.rb +1 -0
- metadata +10 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: aeeec3cd90a84a7b1daf32891039ca8338176ebf4b682a00e02110673eac2d53
|
|
4
|
+
data.tar.gz: 4db3f0193cd06736840bb06a16101278a9124fcbd3db131eb05dfc7965cdf5b8
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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) #
|
|
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:` (
|
|
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
|
-
-
|
|
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
|
|
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
|
|
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
|
-
-
|
|
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` (
|
|
788
|
-
| `reasoning.type
|
|
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`.
|
|
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
|
|
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.
|
|
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
|
|
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
|
-
|
|
102
|
-
|
|
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
|
-
|
|
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.
|