0.0
The project is in a healthy, maintained state
Ruby DSL for building and parsing LLM API requests across OpenAI Responses, OpenAI Chat Completions, Anthropic Messages, Google Gemini, and Bedrock Converse formats
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
 Dependencies
 Project Readme

PromptBuilder

Continuous Integration Ruby Style Guide Gem Version

This gem provides a Ruby DSL for building and parsing LLM API request payloads. The goal of this gem is to provide a single, consistent interface for constructing requests and parsing responses across multiple LLM APIs without locking you into a specific provider or HTTP client. Chat sessions are designed to be serializable so they can be persisted into databases or caches.

The Open Responses API is used as the internal data model. The Open Responses reference documentation provides details on how to use the API and the terminology.

Requests can be generated for and responses can be parsed from these common LLM API formats:

This gem does not include any HTTP client code. It is designed to be used with whatever HTTP library you prefer. You build a request payload, send it to the API yourself, and then parse the response back into Ruby objects.

It was specifically designed to work with the patient_http gem to allow making asynchronous requests to LLM APIs and is used in the patient_llm gem.

Usage

  • Sessions
  • Conversation History
  • Serializing Requests
  • Parsing Responses
  • Agentic Tool Loops
  • Tool Registry
  • Content Types
  • Configuration Options
  • Serialization and Persistence
  • Serializer Compatibility

Sessions

The PromptBuilder::Session class is the main entry point. A session holds the model configuration, conversation history, and tool definitions needed to build a request payload.

session = PromptBuilder::Session.new(
  model: "gpt-5.4",
  instructions: "You are a helpful assistant.",
  temperature: 0.7
)

# Add a user message to the conversation history
session.user("What is the capital of France?")

You can also pass system and input shorthands to create a system message and a user message in one step (equivalent to calling session.system(...) and session.user(...)):

session = PromptBuilder::Session.new(
  model: "gpt-5.4",
  system: "You are a helpful assistant.",
  input: "What is the capital of France?"
)

Passing an option the constructor doesn't recognize raises an ArgumentError. The full list of supported constructor options is available as PromptBuilder::Session::INITIALIZE_OPTIONS.

instructions vs. system and developer messages

Although both can be used to steer the model's behavior, instructions and system/developer messages are not interchangeable in the Open Responses API:

  • instructions is a top-level request parameter, not part of the conversation. It is sent with every request, and in the Open Responses API it applies only to the request it accompanies — it is not carried over when chaining responses with previous_response_id. That makes it safe to change session.instructions at any point in a conversation; the new value simply applies from the next request onward.
  • session.system(...) and session.developer(...) create messages inside the conversation history (the input array). They occupy a position in the transcript like any other message, and once the session is chained with previous_response_id they become part of the server-stored history and are not re-sent on subsequent requests.

As a rule of thumb, use instructions for standing behavior you may want to adjust from request to request, and use system or developer messages when the directive should be a durable part of the conversation record.

When serializing to the other formats this distinction collapses: instructions and any system/developer messages are merged together into the target format's single system field (see Serializer Compatibility).

Conversation History

Build up a multi-turn conversation by adding messages:

session = PromptBuilder::Session.new(model: "gpt-5.4")
session.system("You are a helpful assistant.")
session.user("Hello!")
session.assistant("Hi there! How can I help you today?")
session.user("What's the weather like?")

Messages support the roles user, assistant, system, and developer. A Message exposes system?, user?, and assistant? predicates for checking its role.

Call session.clear to reset the conversation: it removes all items and the instructions, drops any previous_response_id, and returns the session to a fresh local-state start. Model configuration and registered tools are preserved, so the session can be reused for a new conversation.

session.clear   # items and instructions removed; model/config/tools kept

Serializing Requests

Once you've built a session, serialize it to a request payload for the API you want to call. Five target formats are supported:

Open Responses API:

payload = session.request_payload(:open_responses)

Note that session.to_h is not a request payload — it is the persistence format used by Session.from_h and keeps provider-specific extra data, canonical url content keys, and non-replayable items that the Open Responses API would reject. Always build request payloads through request_payload.

OpenAI Chat Completions API:

payload = session.request_payload(:chat_completion)

Anthropic Messages API:

payload = session.request_payload(:messages)

Google Gemini API:

payload = session.request_payload(:gemini)

Amazon Bedrock Converse API:

payload = session.request_payload(:converse)

The payload is a plain Ruby Hash that you can convert to JSON and send to the API with any HTTP client:

require "net/http"
require "json"

uri = URI("https://api.openai.com/v1/responses")
request = Net::HTTP::Post.new(uri)
request["Authorization"] = "Bearer #{api_key}"
request["Content-Type"] = "application/json"
request.body = JSON.generate(session.request_payload(:open_responses))

response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) do |http|
  http.request(request)
end

Parsing Responses

Parse an API response back into an PromptBuilder::Response object using Response.parse with a serializer symbol:

# Open Responses API
response = PromptBuilder::Response.parse(JSON.parse(response_body), :open_responses)

# OpenAI Chat Completions API
response = PromptBuilder::Response.parse(JSON.parse(response_body), :chat_completion)

# Anthropic Messages API
response = PromptBuilder::Response.parse(JSON.parse(response_body), :messages)

# Google Gemini API
response = PromptBuilder::Response.parse(JSON.parse(response_body), :gemini)

# Amazon Bedrock Converse API
response = PromptBuilder::Response.parse(JSON.parse(response_body), :converse)

You can also pass a serializer class directly:

response = PromptBuilder::Response.parse(JSON.parse(response_body), PromptBuilder::Serializers::ChatCompletion)

Response.parse accepts an optional headers: keyword with the HTTP response headers, for formats that return response metadata in headers rather than in the body. The Converse API is one of them: Bedrock puts the response id in the x-amzn-requestid request metadata header, so pass the headers to populate response.id (the header name lookup is case-insensitive):

response = PromptBuilder::Response.parse(JSON.parse(response_body), :converse, headers: http_response_headers)
response.id # => "6e1d5a7b-..."

The Response object provides convenient accessors:

response.text            # => "The capital of France is Paris."
response.completed?      # => true
response.has_tool_calls? # => false
response.usage           # => #<PromptBuilder::Usage input_tokens=25 output_tokens=12 ...>

If the payload is an error returned by the API rather than a completion (e.g. an authentication failure, a validation error, or an AWS Coral service envelope), Response.parse raises a PromptBuilder::ErrorResponseError whose message contains the error reported by the API. Payloads that are neither a completion nor a recognizable error envelope raise a PromptBuilder::UnexpectedPayloadError (which ErrorResponseError subclasses) including the offending body.

PromptBuilder::Response.parse({"error" => {"type" => "invalid_request_error", "message" => "Incorrect API key provided"}}, :chat_completion)
# => raises PromptBuilder::ErrorResponseError: the API returned an error: invalid_request_error: Incorrect API key provided

You can also synthesize a plain-text response without calling an API — useful for canned answers (e.g. halting an agent loop), cached responses, and tests:

response = PromptBuilder::Response.from_text("Authentication failed.",
  model: llm_response.model, usage: llm_response.usage)
response.text        # => "Authentication failed."
response.completed?  # => true

Parsing Streamed Responses

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:

session.stream = true
# ... send the request and read the whole streamed body ...
response = PromptBuilder::Response.parse_stream(response_body, :messages)

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.

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:

accumulator = PromptBuilder::Serializers::Messages.stream_accumulator
http.request(request) do |http_response|
  http_response.read_body { |chunk| accumulator << chunk }
end
response = PromptBuilder::Response.parse(accumulator.result, :messages)

Serializers::<Format>.collapse_stream(body) returns the rebuilt response hash directly. The body can be a String or an enumerable of chunks.

How each format is rebuilt:

  • Open Responses: the full response object is taken from the terminal response.completed, response.incomplete, or response.failed event.
  • 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).
  • 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.
  • 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.
  • 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.

Errors:

  • 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.
  • 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.
  • A malformed stream, such as an event-stream checksum mismatch or invalid JSON in an event, raises a PromptBuilder::ParseError.
  • 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.
  • Unknown event types are ignored, so new event types the providers add don't break parsing.

Structured Output

Use json_output to request JSON Schema structured output and parsed_json to read it back:

schema = {
  "type" => "object",
  "properties" => {"answer" => {"type" => "string"}},
  "required" => ["answer"]
}

session.json_output(schema, name: "response", strict: true)
payload = session.request_payload(:messages)  # works with all five serializers

# After parsing the API response:
data = response.parsed_json    # => {"answer" => "..."} or nil if the text isn't valid JSON
data = response.parsed_json!   # raises PromptBuilder::ParseError (including the raw text) instead

parsed_json strips fenced ```json wrappers, a common provider quirk. json_output is sugar for setting session.text to the canonical format wire hash, so direct session.text = {...} assignment keeps working.

Reasoning / Extended Thinking

Use think to configure reasoning portably; each serializer maps it to its native parameter:

session.think                        # API defaults (adaptive thinking on Messages)
session.think(effort: :medium)       # portable across serializers
session.think(budget_tokens: 8_000)  # explicit token budget where supported
session.think(false)                 # turn reasoning off
session.think(false, effort: :low)   # off, with an effort level for Messages
session.reasoning = nil              # no reasoning parameter; the API default applies
  • 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.
  • 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.
  • 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.
  • 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.
  • 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:.
  • 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 ¹⁵.

Direct session.reasoning = {...} assignment keeps working for provider-specific keys (e.g. Anthropic's type and display, Gemini's summary).

Agentic Tool Loops

You can register tool definitions on a session, add API responses to the conversation, and manually append tool outputs to build an agentic loop:

session = PromptBuilder::Session.new(model: "gpt-5.4")

session.register_tool(
  "get_weather",
  description: "Get the current weather for a city.",
  parameters: {
    "type" => "object",
    "properties" => {
      "city" => {"type" => "string", "description" => "The city name"}
    },
    "required" => ["city"]
  }
)

session.user("What's the weather in Paris?")

loop do
  payload = session.request_payload(:chat_completion)
  response_body = call_api(payload)  # Your HTTP call
  response = PromptBuilder::Response.parse(response_body, :chat_completion)

  session.add_response(response)

  unless response.has_tool_calls?
    puts response.text
    break
  end

  # Invoke tool handlers for each tool call in this response and append the
  # output back to the session before the next iteration.
  response.tool_calls.each do |call|
    result = call_tool(call.name, call.parsed_arguments)  # invoke your logic for the tool
    session.add_function_call_output(call_id: call.call_id, result: result.to_s)
  end
end

The add_response method appends the model's output items (messages, tool calls, reasoning, etc.) to the session's conversation history. You add FunctionCallOutput items manually after invoking each tool, then loop until the model produces a final text response.

Tool Registry

For tools that are shared across multiple sessions, you can use a ToolRegistry:

registry = PromptBuilder::ToolRegistry.new

registry.register(
  "search",
  description: "Search the knowledge base.",
  parameters: {
    "type" => "object",
    "properties" => {
      "query" => {"type" => "string"}
    },
    "required" => ["query"]
  }
) do |args|
  KnowledgeBase.search(args["query"])
end

# Apply all tools from the registry to a session
session = PromptBuilder::Session.new(model: "gpt-5.4")
session.register_tools(registry)

There is also a global registry available on the PromptBuilder module:

PromptBuilder.register_tool("search", description: "Search the knowledge base.") do |args|
  KnowledgeBase.search(args["query"])
end

session.register_tools(PromptBuilder.tool_registry)

Use use_tools to attach a subset of registry tools by name without copying schemas by hand. Unknown names raise a ToolNotFoundError, so a typo fails fast instead of producing a silently absent tool:

session.use_tools("weather", "traffic_conditions")   # from PromptBuilder.tool_registry
session.use_tools                                     # all tools in the registry
session.use_tools(:weather, registry: my_registry)    # explicit registry

Tool definitions are copied onto the session in all cases, so later registry changes don't affect the session and the tools survive to_h/from_h round-trips.

Remove tools from a session with remove_tool (by name; accepts a string or symbol) or clear_tools (all at once). remove_tool returns the removed Tools::Definition, or nil if no tool by that name was registered; clear_tools returns the removed definitions.

session.remove_tool("weather")   # => removed Tools::Definition, or nil
session.clear_tools              # => [<removed definitions>]

Content Types

Message content can be a plain string or an array of structured content objects for multi-modal input. Content can be provided as raw Hashes or as PromptBuilder::Content objects.

Images

Send an image by URL or as base64-encoded data:

# Image from a URL
session.user([
  PromptBuilder::Content::InputText.new(text: "What is in this image?"),
  PromptBuilder::Content::InputImage.new(url: "https://example.com/photo.jpg")
])

# Image with a detail level hint
session.user([
  PromptBuilder::Content::InputText.new(text: "Describe this image in detail."),
  PromptBuilder::Content::InputImage.new(
    url: "https://example.com/photo.jpg",
    detail: "high"
  )
])

# Image from raw binary data using a data URL
session.user([
  PromptBuilder::Content::InputText.new(text: "What is in this image?"),
  PromptBuilder::Content::InputImage.new(
    url: PromptBuilder.data_url(File.binread("photo.png"), "image/png")
  )
])

Files

Attach a file by URL or as raw binary data:

# File from a URL
session.user([
  PromptBuilder::Content::InputText.new(text: "Summarize this document."),
  PromptBuilder::Content::InputFile.new(url: "https://example.com/report.pdf")
])

# File from raw binary data with a filename and media type
session.user([
  PromptBuilder::Content::InputText.new(text: "What does this spreadsheet contain?"),
  PromptBuilder::Content::InputFile.new(
    url: PromptBuilder.data_url(File.binread("data.csv"), "text/csv"),
    filename: "data.csv"
  )
])

# Reference a previously uploaded file by id (OpenAI Files API, Gemini Files API)
session.user([
  PromptBuilder::Content::InputText.new(text: "Summarize this."),
  PromptBuilder::Content::InputFile.new(file_id: "file_abc123", media_type: "application/pdf")
])

Videos

session.user([
  PromptBuilder::Content::InputText.new(text: "Summarize what happens in this video."),
  PromptBuilder::Content::InputVideo.new(url: "https://example.com/clip.mp4")
])

Using Hashes

You can also pass plain Hashes instead of content objects:

session.user([
  {"type" => "input_text", "text" => "What is in this image?"},
  {"type" => "input_image", "url" => "https://example.com/photo.jpg"}
])

Supported content types

Type Class Description
input_text Content::InputText Text input
input_image Content::InputImage Image input (URL or base64)
input_file Content::InputFile File input (URL or base64)
input_video Content::InputVideo Video input (URL)
output_text Content::OutputText Text output from the model
refusal Content::RefusalContent Refusal content from the model

Configuration Options

Sessions support a wide range of configuration options that map to common API parameters:

session = PromptBuilder::Session.new(
  model: "gpt-5.4",
  instructions: "You are a helpful assistant.",
  temperature: 0.7,
  top_p: 0.9,
  max_output_tokens: 1024,
  frequency_penalty: 0.5,
  presence_penalty: 0.5,
  parallel_tool_calls: true,
  reasoning: {"effort" => "high"},
  text: {"format" => {"type" => "json_object"}},
  tool_choice: "auto",
  truncation: "auto",
  prompt_cache_key: "account-123",
  prompt_cache_retention: "24h",
  store: true,
  metadata: {"user_id" => "123"}
)

Provider-Specific Extra Parameters

The extra attribute on sessions, content blocks, and tool definitions lets you pass provider-specific parameters that are not part of the Open Responses canonical format. Each serializer recognizes a defined set of extra keys and maps them to the appropriate location in the target format. Unrecognized keys are silently ignored.

On a session, extra is a read/write attribute that can be set at any point in the session's life. On content blocks and tool definitions it is set with keyword arguments at construction time.

Session Extra

Pass provider-specific top-level request parameters via session.extra:

# Anthropic Messages: top_k, stop_sequences, cache_control
session = PromptBuilder::Session.new(
  model: "claude-sonnet-4-20250514",
  extra: {
    "top_k" => 40,
    "stop_sequences" => ["\n\nHuman:"],
    "cache_control" => {"type" => "ephemeral"}
  }
)

# OpenAI Chat Completions: stop, seed, logit_bias, n, web_search_options
session = PromptBuilder::Session.new(
  model: "gpt-4o",
  extra: {
    "stop" => ["END", "\n\n"],
    "seed" => 42,
    "web_search_options" => {"search_context_size" => "medium"}
  }
)

# Google Gemini: safety_settings, cached_content, stop_sequences, top_k, seed
session = PromptBuilder::Session.new(
  model: "gemini-2.0-flash",
  extra: {
    "safety_settings" => [
      {"category" => "HARM_CATEGORY_HATE_SPEECH", "threshold" => "BLOCK_ONLY_HIGH"}
    ],
    "cached_content" => "cachedContents/abc123",
    "stop_sequences" => ["END"],
    "top_k" => 40,
    "seed" => 42
  }
)

# Amazon Bedrock Converse: stop_sequences, guardrail_config, additional_model_request_fields
session = PromptBuilder::Session.new(
  model: "anthropic.claude-v2",
  extra: {
    "stop_sequences" => ["\n\nHuman:"],
    "guardrail_config" => {
      "guardrailIdentifier" => "my-guardrail",
      "guardrailVersion" => "1"
    },
    "additional_model_request_fields" => {"top_k" => 50}
  }
)

The same data can be set after the session has been created by assigning to session.extra. Keys are normalized to strings, so symbol keys work too.

session = PromptBuilder::Session.new(model: "anthropic.claude-v2")

session.extra # => {}

# Assign the whole hash.
session.extra = {guardrail_config: {"guardrailIdentifier" => "my-guardrail", "guardrailVersion" => "1"}}

# `extra` returns a copy, so read, modify, and assign it back to change one key.
session.extra = session.extra.merge("stop_sequences" => ["\n\nHuman:"])
session.extra = session.extra.except("stop_sequences")

session.extra = nil # clears it

Important

session.extra never returns nil and its keys are always strings. Because the reader returns a copy, mutating it in place has no effect on the session — session.extra["seed"] = 42 is silently discarded. Assign the modified hash back as shown above.

Content Extra

Content blocks support provider-specific attributes via keyword arguments that are captured in the extra hash:

# Anthropic Messages: cache_control on content blocks for prompt caching
session.system([
  PromptBuilder::Content::InputText.new(
    text: "You are an assistant with a large knowledge base...",
    cache_control: {"type" => "ephemeral"}
  )
])

# Anthropic Messages: citations opt-in on document blocks
session.user([
  PromptBuilder::Content::InputFile.new(
    url: "https://example.com/report.pdf",
    citations: {"enabled" => true}
  ),
  PromptBuilder::Content::InputText.new(text: "Summarize with citations.")
])

# Bedrock Converse: cache_point markers
session.user([
  PromptBuilder::Content::InputText.new(
    text: "Long context that should be cached...",
    cache_point: true
  )
])

# Bedrock Converse: cache_point on tool definitions and tool results
session.register_tool(
  "search",
  description: "Search the knowledge base.",
  parameters: {"type" => "object", "properties" => {"query" => {"type" => "string"}}},
  cache_point: true
)
session.add_item(
  PromptBuilder::Items::FunctionCallOutput.new(
    call_id: "call_1",
    output: "Search results...",
    cache_point: true
  )
)

Tool Definition Extra

Tool definitions accept provider-specific parameters as keyword arguments:

# Anthropic Messages: cache_control on tool definitions
session.register_tool(
  "search",
  description: "Search the knowledge base.",
  parameters: {"type" => "object", "properties" => {"query" => {"type" => "string"}}},
  cache_control: {"type" => "ephemeral"}
)

Recognized Extra Keys by Serializer

Session extra keys can be supplied to the constructor or assigned later with session.extra=.

Serializer Session Extra Keys Content Extra Keys Tool Extra Keys
Chat Completions stop, seed, logit_bias, n, prediction, web_search_options, modalities, audio file_id, media_type —
Messages top_k, stop_sequences, cache_control cache_control, citations, file_id, media_type cache_control
Gemini safety_settings, cached_content, stop_sequences, top_k, seed, candidate_count, response_modalities, media_resolution file_id, media_type, thought_signature —
Converse stop_sequences, guardrail_config, additional_model_request_fields, additional_model_response_field_paths, performance_config, prompt_variables media_type, cache_point cache_point

Serialization and Persistence

Sessions and responses can be serialized to and from Hashes for storage or transmission:

# Serialize a session to a Hash
hash = session.to_h

# Restore a session from a Hash
restored_session = PromptBuilder::Session.from_h(hash)

This makes it straightforward to persist conversation state in a database or cache between requests.

Warning

to_h is the persistence format, not a request payload. It retains provider-specific extra data and non-replayable items that APIs reject. Send the output of request_payload(serializer) to APIs, and use to_h/from_h only for storage.

Serializer Compatibility

The Open Responses format is the canonical data model for this gem. When serializing to other formats, some features may not be available because either the target API does not support them or because the Open Responses format does not expose parameters unique to the target API. If you attempt to use a feature that is not supported by a particular serializer, it will be silently omitted from the serialized output.

Session Fields

The following table shows which session configuration fields are supported by each serializer. ❌ means the field is silently omitted from the serialized output. Partial support is noted inline.

Session Field ChatCompletion Messages Gemini Converse
background ❌ ❌ ❌ ❌
frequency_penalty ✅ ❌ ✅ → generationConfig.frequencyPenalty ❌
include ❌ ❌ ❌ ❌
max_tool_calls ❌ ❌ ❌ ❌
metadata ✅ user_id key only ❌ ✅ → requestMetadata¹³
parallel_tool_calls ✅ ✅ ❌ ❌
presence_penalty ✅ ❌ ✅ → generationConfig.presencePenalty ❌
prompt_cache_key ✅ ❌ ❌ ❌
prompt_cache_retention ✅ ❌ ❌ ❌
reasoning effort key only budget_tokens/display/effort/type only¹⁴ budget_tokens/effort/summary: "auto" only Anthropic and OpenAI models only → additionalModelRequestFields¹⁵
safety_identifier ✅ ✅ → metadata.user_id ❌ ❌
service_tier ✅ auto/standard_only only unspecified/standard/flex/priority only ✅ → serviceTier.type
store ✅ ❌ ✅ ❌
stream ✅ ✅ endpoint-selected⁸ endpoint-selected⁸
stream_options include_usage/include_obfuscation only¹⁶ ❌ ❌ ❌
text format/verbosity only format.type=json_schema only format key only format.type == json_schema only
top_logprobs ✅ ❌ ✅ → responseLogprobs/logprobs ❌
truncation ❌ ❌ ❌ ❌

Content Types

Content Type ChatCompletion Messages Gemini Converse
InputText ✅ ✅ ✅ ✅
InputImage user messages only⁷ user messages only⁵ user messages only⁶ base64 or S3 URI only
InputFile user messages only¹⁰ user messages only¹ user messages only⁶ base64 or S3 URI only²
InputVideo ❌ ❌ URL required (Google-hosted URI only)⁶ S3 URI only
OutputText ✅ (annotations dropped on request)¹¹ ✅ (annotations ↔ citations)¹² ✅ ✅
RefusalContent dropped⁹ dropped⁹ dropped⁹ dropped⁹
Reasoning items ❌ ✅³ ✅⁴ signed only → reasoningContent³
Compaction items ❌ ❌ ❌ ❌
ItemReference items ❌ ❌ ❌ ❌

¹ 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. ² Converse format infers the document type from the filename or URL extension. ³ 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. ⁴ Gemini format passes thoughtSignature through transparently on reasoning, text, and function-call parts. redacted_thinking blocks are silently skipped. ⁵ 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). ⁶ Gemini supports inline base64 data or any URL. For files, set media_type or use a recognized filename/URL extension. ⁷ 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. ⁸ 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). ⁹ 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. ¹⁰ 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. ¹¹ 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. ¹² 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. ¹³ Converse requestMetadata only accepts string key/value pairs. This serializer stringifies scalar metadata values and silently omits nested arrays or objects.

¹⁴ 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.

¹⁵ 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.

¹⁶ 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}.

Features Not Accessible Through Open Responses

These target API features are not available through the Open Responses canonical format because Open Responses does not expose the equivalent parameters:

Feature ChatCompletion Messages Gemini Converse
Audio input ✅ — ✅ —
Audio output / TTS ✅ — ✅ —
Output modalities selection ✅ — ✅ —
top_k sampling — ✅ ✅ —
seed ✅ — ✅ —
Stop sequences ✅ ✅ ✅ ✅
Structured output JSON schema ✅ ✅ ✅ ✅
Top-level cache_control — ✅ — —
logit_bias ✅ — — —
Multiple candidates (n) ✅ — ✅ (request unsupported; parsing keeps only the first candidate) —
Speculative decoding (prediction) ✅ — — —
tool_choice allowed_tools shape ✅ — — —
Custom (non-function) tool types ✅ — — —
Strict tool definitions ✅ ✅ ✅ (VALIDATED exists, but per-tool strict is not supported by this gem) —
Deprecated Chat functions / function_call / max_tokens / user fields ✅ — — —
Built-in web search (web_search_options) ✅ ✅ ✅ —
Configurable safety thresholds — — ✅ —
Guardrail policies (guardrailConfig, guardContent) — — — ✅
Cross-region routing (inference profiles) — — — ✅
Prompt caching (cache_control markers) — ✅ — —
Prompt caching (cachePoint blocks) — — — ✅
Citations on documents and tool results — ✅ — ✅
additionalModelRequestFields / additionalModelResponseFieldPaths — — — ✅
Bedrock Prompt Management variables (promptVariables) — — — ✅
search_result content blocks — ✅ — ✅
Audio content blocks ✅ — ✅ ✅
MCP connectors (mcp_servers) — ✅ — —
Code execution container reuse — ✅ — —
Geographic inference routing (inference_geo) — ✅ — —
Beta API headers (anthropic-beta, etc.) — ✅ ✅ —
Built-in code execution — ✅ ✅ —
Built-in computer use — ✅ ✅ —
Built-in bash / text editor / memory tools — ✅ — —
Built-in URL context retrieval (urlContext) — — ✅ —
Built-in Google Maps grounding — — ✅ —
Built-in semantic file search (fileSearch) — — ✅ —
Context caching (cachedContent resource) — — ✅ —
Tool-call mode VALIDATED — — ✅ —
toolConfig.retrievalConfig / includeServerSideToolInvocations — — ✅ —
responseJsonSchema (newer JSON Schema variant) — — ✅ —
mediaResolution (image/video token budget) — — ✅ —
audioTimestamp, speechConfig — — ✅ —
enableEnhancedCivicAnswers — — ✅ —
routingConfig / modelSelectionConfig — — ✅ —
thinkingConfig.includeThoughts detail control — — ✅ (reasoning.summary: "auto" only maps to true) —
thinkingConfig.thinkingLevel — — ✅ (use reasoning.effort) —
Per-Part videoMetadata (offset/FPS) — — ✅ —
Top-level labels (Vertex flavor) — — ✅ —

For Messages specifically:

  • Prompt caching — Anthropic's cache_control: {"type": "ephemeral"} markers on system blocks, message content blocks, tool definitions, document blocks, and the top-level request have no Open Responses equivalent. The prompt_cache_key and prompt_cache_retention session fields are silently omitted from the Messages payload.
  • Citations — text-block citations are preserved via OutputText.annotations. The citations: {"enabled": true} opt-in on document blocks and tool results is not modeled by this gem.
  • Geographic inference routing — Anthropic's inference_geo request field has no canonical Open Responses field. Response-side usage.inference_geo is preserved in response.usage.input_tokens_details.
  • API versioning / beta headers — this gem produces no HTTP, so anthropic-version, anthropic-beta, and similar headers must be set on your HTTP client. Features behind a beta header (Files API, MCP, code execution containers, extended caching, etc.) still produce valid request payloads through this gem when their request-body shape is supported.

Chat Completions-specific notes

Serializing a session without model set, or one that produces no messages at all, raises a PromptBuilder::UnsupportedFormatError.

Request-side mappings worth calling out:

Canonical field / value Chat Completions mapping
instructions leading {"role": "system", "content": ...} message (use session.developer(...) if your model prefers developer)
max_output_tokens max_completion_tokens
safety_identifier safety_identifier (the legacy user field is not used)
prompt_cache_key / prompt_cache_retention passed through directly
text.format response_format (the OR-canonical flat json_schema is reshaped into {"type": "json_schema", "json_schema": {...}})
text.verbosity top-level verbosity
reasoning.effort reasoning_effort
reasoning.type: "disabled" reasoning_effort: "none" (replaces any effort)
tool_choice: {"type": "function", "name": ...} {"type": "function", "function": {"name": ...}}
InputFile.file_id {"type": "file", "file": {"file_id": ...}}
InputFile with data URL (+ optional filename/media_type) {"type": "file", "file": {"filename": ..., "file_data": "data:<media_type>;base64,..."}}
InputImage with data URL (+ media_type) {"type": "image_url", "image_url": {"url": "data:<media_type>;base64,..."}}

Chat Completions request features with no canonical Open Responses field are available through session.extra (stop, seed, logit_bias, n, prediction, web_search_options, modalities, audio). Features not exposed at all include input_audio content blocks, custom tools, tool_choice: {"type": "allowed_tools", ...}, and the deprecated functions, function_call, max_tokens, and user fields. Use the modern canonical fields when available (tools, tool_choice, max_output_tokens, safety_identifier, prompt_cache_key).

Response-side behavior and limitations:

  • Responses with multiple choices (n > 1) parse only the first choice; additional candidates are dropped.
  • 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).
  • system_fingerprint is exposed on response.provider_data.
  • service_tier is populated when present on the response.
  • message.annotations (URL citations from web_search_options) are copied onto OutputText.annotations.
  • Audio responses, custom tool calls, legacy message.function_call, and unknown response content block types are silently skipped because they have no canonical representation in this gem.
  • finish_reason mappings: stop/tool_calls/function_call → completed, length → incomplete, content_filter → failed. The legacy function_call reason is included so older models still surface as completed.

Anthropic Messages-specific mappings

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.

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.

A few features map between the canonical Open Responses format and the Messages API in non-obvious ways:

Canonical field / value Messages mapping
InputImage.file_id {"type": "image", "source": {"type": "file", "file_id": ...}} (Files API beta)
InputFile.file_id {"type": "document", "source": {"type": "file", "file_id": ...}} (Files API beta)
FunctionCallOutput.status ∈ incomplete, failed, error tool_result.is_error: true
safety_identifier metadata.user_id
parallel_tool_calls: false tool_choice.disable_parallel_tool_use: true (forces tool_choice.type to auto if unset)
reasoning asking for thinking thinking (thinking.type defaults to adaptive; see note ¹⁴)
reasoning.budget_tokens thinking.budget_tokens (with thinking.type defaulted to enabled)
reasoning.effort output_config.effort (forwarded as is)
reasoning.type thinking.type (forwarded as is, e.g. adaptive, between_tools, disabled, or enabled)
text.format.type: "json_schema" output_config.format (type and schema only; name, description, and strict are ignored)
Tools::Definition#strict tool definition strict: true
tool_choice: "required" {"type": "any"}
tool_choice: {"type": "function", "name": ...} {"type": "tool", "name": ...}

Response stop reasons are mapped to Open Responses statuses as follows: end_turn, tool_use, stop_sequence, and pause_turn → completed; max_tokens → incomplete; refusal → failed. When stop_sequence is matched, the matched text is exposed via response.incomplete_details["stop_sequence"]. Anthropic stop_details and response container metadata are exposed via response.incomplete_details. Additional usage details (cache_creation breakdown, service_tier, inference_geo, server_tool_use, and cache token counts) are surfaced through response.usage.input_tokens_details.

Built-in tool response content blocks (server_tool_use, web_search_tool_result, web_fetch_tool_result, code_execution_tool_result, bash_code_execution_tool_result, tool_search_tool_result, mcp_tool_use, mcp_tool_result, container_upload, search_result) are silently skipped on parse, since this gem has no canonical representation for them.

Gemini-specific notes

Serializing a session without model set (the model belongs in the request URL, but it is validated at serialization time), or one that produces an empty contents array, raises a PromptBuilder::UnsupportedFormatError.

Request-side mappings worth calling out:

Canonical field / value Gemini mapping
instructions + system/developer messages merged into systemInstruction.parts[]
max_output_tokens generationConfig.maxOutputTokens
temperature / top_p generationConfig.temperature / topP
presence_penalty / frequency_penalty generationConfig.presencePenalty / frequencyPenalty
top_logprobs: N generationConfig.{responseLogprobs: true, logprobs: N}
text.format == "text" generationConfig.responseMimeType = "text/plain"
text.format == "json_object" generationConfig.responseMimeType = "application/json"
text.format == "json_schema" generationConfig.responseMimeType = "application/json" + responseSchema (read from format.json_schema.schema or format.schema)
reasoning.budget_tokens generationConfig.thinkingConfig.thinkingBudget (raises alongside a mapped reasoning.effort)
reasoning.type: "disabled" generationConfig.thinkingConfig.thinkingBudget: 0 (replaces any budget or effort)
reasoning.effort generationConfig.thinkingConfig.thinkingLevel (upcased and forwarded as is)
tool_choice: "auto" / "none" / "required" toolConfig.functionCallingConfig.mode = AUTO / NONE / ANY
tool_choice: {"type": "function", "name": ...} mode = ANY + allowedFunctionNames: [name]
Tool definitions single tools[0].functionDeclarations[] entry

Content and message restrictions:

  • Assistant messages map to role: "model"; consecutive same-role turns are merged automatically.
  • InputImage, InputFile, and InputVideo are only supported in user messages; the same content on an assistant message is omitted.
  • InputImage accepts any URL, a base64 data URL (with required media_type), or a Files API file_id.
  • InputFile accepts any URL, a base64 data URL, or file_id. media_type is required when not inferable from a filename or URL extension. Recognized extensions cover documents (pdf, txt, md/markdown, html/htm, csv, json, xml, rtf), images (png, jpg/jpeg, webp, heic, heif), audio (mp3, wav, aiff, aac, ogg, flac), and video (mp4, mov, webm, mpeg/mpg).
  • InputVideo requires a URL; raw bytes are not modeled.
  • RefusalContent is dropped silently so a parsed Chat Completions refusal can sit in session history without breaking subsequent serialization.
  • Reasoning items round-trip via parts[].thought with thoughtSignature preserved. redacted_thinking, summary, and unknown reasoning block types are silently skipped.
  • FunctionCall.arguments must parse to a JSON object — Gemini's functionCall.args is a Struct.
  • FunctionCallOutput content must be text-only (InputText/OutputText or a plain string); other content types are omitted. When output parses to a JSON object it is forwarded as the functionResponse.response Struct; otherwise it is wrapped as {"result": ...}. The output must reference a prior FunctionCall so its name can be resolved (Gemini's functionResponse.name is the tool name, not the call id).
  • Compaction and ItemReference items are silently skipped.
  • tool_choice without registered tools is omitted (along with unsupported tool_choice shapes).

Response-side limitations:

  • Unknown response Part shapes (inlineData, fileData, executableCode, codeExecutionResult, videoMetadata, server-side toolCall/toolResponse, or any Part without a recognized content key) are silently skipped.
  • Only candidates[0] is parsed. When candidateCount > 1, additional candidates are dropped (their index is preserved on provider_data).
  • Function-call id from the response is preserved when present; otherwise the parser synthesizes gemini_call_<seed>_<n> so multiple calls in one response share a deterministic seed.
  • 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.
  • An empty candidates array combined with promptFeedback.blockReason is mapped to failed.
  • usageMetadata.cachedContentTokenCount, toolUsePromptTokenCount, promptTokensDetails, cacheTokensDetails, and toolUsePromptTokensDetails populate response.usage.input_tokens_details. thoughtsTokenCount and candidatesTokensDetails populate response.usage.output_tokens_details.
  • 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).

Converse-specific notes

Serializing a session without model set (populates the required modelId parameter) raises a PromptBuilder::UnsupportedFormatError.

Request-side restrictions worth calling out:

Canonical field / value Converse mapping
instructions + system/developer messages merged into top-level system array
max_output_tokens inferenceConfig.maxTokens
temperature / top_p inferenceConfig.temperature / inferenceConfig.topP
metadata requestMetadata with scalar values stringified
service_tier serviceTier.type
text.format.type == "json_schema" outputConfig.textFormat with structure.jsonSchema
tool_choice: "auto" {"auto": {}}
tool_choice: "required" {"any": {}}
tool_choice: {"type": "function", "name": ...} {"tool": {"name": ...}}
reasoning (Anthropic models) additionalModelRequestFields.thinking / additionalModelRequestFields.output_config.effort
reasoning (OpenAI models) additionalModelRequestFields.reasoning
reasoning (OpenAI gpt-oss models) additionalModelRequestFields.reasoning_effort (effort only)
reasoning (other models) raises UnsupportedFormatError (nothing is sent for a bare think or think(false))

Content and message restrictions:

  • The first message must have role user; the request raises if it doesn't. Consecutive same-role messages are merged automatically.
  • system and developer messages must contain text only. Non-text content is omitted because Converse system blocks only map to text, guardContent, or cachePoint, and the latter two are not modeled by this gem.
  • InputImage requires either base64 data or an s3:// URI; content with an arbitrary public URL is omitted. file_id and detail have no Converse equivalent and are silently ignored.
  • InputFile requires either a base64 data URL or an s3:// URI; document name is auto-derived from filename / URL and sanitized to Bedrock's allowed character set ([A-Za-z0-9 \-()\[\]]{1,256}), with collisions disambiguated within a single request.
  • InputFile.file_id is silently ignored because Converse does not accept provider file IDs for documents.
  • 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.
  • 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.
  • 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.
  • Compaction and ItemReference items are silently skipped.
  • FunctionCall.arguments must parse to a JSON object; non-object JSON values raise (Bedrock's toolUse.input requires an object).
  • 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.
  • tool_choice: "none" and tool_choice without registered tools are both omitted.
  • Converse API request features with no canonical Open Responses field are available through session.extra (stop_sequences, guardrail_config, additional_model_request_fields, additional_model_response_field_paths, performance_config, prompt_variables) and content extra (cache_point). cache_point is also recognized on tool definitions (the marker is appended to the toolConfig.tools array) and on FunctionCallOutput items (the marker is appended after the toolResult content block). Features not exposed at all include guardContent, audio blocks, and searchResult blocks.
  • The serialized payload includes modelId as a convenience; the Converse REST endpoint takes the model id in the URL path (/model/{modelId}/converse), and AWS REST-JSON services ignore unrecognized body members. Remove it from the body if you prefer a strictly minimal payload.

Response-side limitations:

  • Unknown content block keys (e.g. citationsContent, guardContent) are silently skipped.
  • metrics.latencyMs, trace (guardrail and prompt-router trace events), additionalModelResponseFields, performanceConfig, and the raw serviceTier object are exposed on response.provider_data. response.service_tier is also populated from serviceTier.type.
  • stopReason mappings: end_turn / tool_use / stop_sequence → completed, max_tokens / model_context_window_exceeded → incomplete, guardrail_intervened / content_filtered / malformed_model_output / malformed_tool_use → failed. Unlike the Messages serializer, the matched stop sequence text is not surfaced separately because Converse does not echo it back unless you request provider-specific fields through additionalModelResponseFieldPaths (available via the additional_model_response_field_paths session extra; the requested fields appear on response.provider_data).
  • usage.cacheReadInputTokens and usage.cacheWriteInputTokens populate response.usage.input_tokens_details["cached_tokens"] and ["cache_creation_input_tokens"]. Cache writes require cachePoint markers in the request, which are emitted via the cache_point extra on content, tool definitions, and FunctionCallOutput items.

Installation

Add this line to your application's Gemfile:

gem "prompt_builder"

Then execute:

$ bundle

Or install it yourself as:

$ gem install prompt_builder

Contributing

Open a pull request on GitHub.

Please use the standardrb syntax and lint your code with standardrb --fix before submitting.

License

The gem is available as open source under the terms of the MIT License.