prompt_builder 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3b3f2f981ac5ffaacac8d0f46d3cde5b2429042f34846b36b6a0154fee9858f4
4
- data.tar.gz: 7aceebc0337cd9f6f201ccbf822caca50d53ff23e3d2ed165bd7d114c9d9120f
3
+ metadata.gz: e762383ea51918fa77b5962f0137775551a37da1c228a37d24364011c8d21f73
4
+ data.tar.gz: 64e2a4e22feb0a8e0e5752f4807b79452c3cb700dd8b5740500f11cbe0b4217e
5
5
  SHA512:
6
- metadata.gz: 0fdf71137c15a30e7e2cc5740bfa5fc4b7b9a2d31b9959821f84e879b718150dbfd054e15b19e13f53caead242d70ddaaf06e315e79044f77dce1500c6e374cf
7
- data.tar.gz: 935a2ac05da1aa179f6107d46e0d37b80b79762111f95f16cda73924e9d9a48733cff1761413b4a8dedb56b8f880b517ed2cd9e196f5ea648e5ae13d909c9e10
6
+ metadata.gz: b7eb3a4806f9cd7a24e00652659b7a5d641ae5877187bd714afd0e4eaa86c7a7901eebcfdf11202497d0b98cce03f9a0fec10f1840883f91b4b87be3a9f2c387
7
+ data.tar.gz: 2b731def4d25b2233b208d3fb3d8867ee17cce9356cb84b3d57d3d3043116af5eb55ca7888de00c329c3637b209bbc70ea11dada040bc5f97f9701c85a0fa8ee
data/CHANGELOG.md CHANGED
@@ -4,20 +4,29 @@ 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
- ## Unreleased
7
+ ## 0.1.2
8
+
9
+ ### Fixed
10
+
11
+ - `Session#add_function_call_output` raised a `NameError`; it now correctly builds an `Items::FunctionCallOutput`.
12
+ - Provider-specific `extra` data on items, content blocks, and tool definitions (e.g. `cache_control`, `cache_point`, `thought_signature`, `media_type`) leaked into Open Responses request payloads and would be rejected as unknown parameters. It is now stripped from `request_payload(:open_responses)`.
13
+ - The Messages serializer emitted all `OutputText.annotations` as text-block `citations`, including annotation shapes from other providers (e.g. Chat Completions `url_citation`) that the Anthropic API rejects. Only valid Anthropic citation types are now emitted.
14
+ - The Chat Completions serializer emitted tool messages with an empty content array when a tool result contained no text content; it now collapses to an empty string.
15
+ - The Chat Completions response parser duplicated message-level `annotations` and `logprobs` onto every text block; they are now attached only to the first.
16
+ - `PromptBuilder.parse_data_url` now accepts data URLs with media type parameters (e.g. `data:text/plain;charset=utf-8;base64,...`).
17
+
18
+ ### Added
19
+
20
+ - `Response#provider_data` as an alias for `Response#extra`.
21
+
22
+ ## 0.1.1
8
23
 
9
24
  ### Changed
10
25
 
11
- - Serializers now silently omit unsupported features from the serialized output
12
- instead of raising `UnsupportedFormatError`. Unsupported session fields, content
13
- types, enum values, sub-keys, `tool_choice` variants, and `Compaction`/`ItemReference`
14
- items are dropped from request payloads; unrecognized blocks, candidates, and choices
15
- are skipped when parsing responses. `UnsupportedFormatError` is still raised for
16
- missing required data (e.g. no `model`, no messages, a `json_schema` format without a
17
- schema), structural problems, genuine conflicts, unparseable function-call arguments,
18
- and streaming response chunks.
26
+ - Responses will now raise an `UnsupportedFormatError` if the response shape is missing key elements.
27
+ - Removed the default `max_tokens` value of 4096 from the request serializer. If `max_output_tokens` is not set on the session, the request will simply omit the `max_tokens` parameter, allowing the API to apply its own defaults.
19
28
 
20
- ## 1.0.0
29
+ ## 0.1.0
21
30
 
22
31
  ### Added
23
32
 
data/README.md CHANGED
@@ -78,11 +78,11 @@ Once you've built a session, serialize it to a request payload for the API you w
78
78
  **Open Responses API**:
79
79
 
80
80
  ```ruby
81
- payload = session.to_h
82
- # or
83
81
  payload = session.request_payload(:open_responses)
84
82
  ```
85
83
 
84
+ 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`.
85
+
86
86
  **OpenAI Chat Completions API**:
87
87
 
88
88
  ```ruby
@@ -117,7 +117,7 @@ uri = URI("https://api.openai.com/v1/responses")
117
117
  request = Net::HTTP::Post.new(uri)
118
118
  request["Authorization"] = "Bearer #{api_key}"
119
119
  request["Content-Type"] = "application/json"
120
- request.body = JSON.generate(session.to_h)
120
+ request.body = JSON.generate(session.request_payload(:open_responses))
121
121
 
122
122
  response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) do |http|
123
123
  http.request(request)
@@ -535,7 +535,7 @@ The following table shows which session configuration fields are supported by ea
535
535
  ⁹ `RefusalContent` blocks are dropped silently by all request serializers so a parsed refusal can sit in session history without breaking subsequent `request_payload` calls. A message left empty after stripping is omitted entirely.
536
536
  ¹⁰ Chat Completions sends `InputFile` as a `{"type": "file", "file": {...}}` content block. `file_id` (Files API) and base64 data URL (with optional `filename`/`media_type`) are supported. A URL-only `InputFile` (non-data URL) is omitted because Chat Completions has no remote-URL form for files.
537
537
  ¹¹ `OutputText.annotations` (e.g. URL citations from `web_search_options`) are parsed onto the assistant message and round-trip through session history, but are dropped silently on Chat Completions and Converse request serialization since those formats have no request-side equivalent. `OutputText.logprobs` is likewise dropped.
538
- ¹² Messages text-block `citations` are parsed into `OutputText.annotations` and emitted back as `citations` when serializing Messages history. Document/tool-result citation opt-ins are still not modeled.
538
+ ¹² Messages text-block `citations` are parsed into `OutputText.annotations` and emitted back as `citations` when serializing Messages history. Annotations from other providers (e.g. a Chat Completions `url_citation`) are silently dropped since they are not valid Anthropic citation shapes. Document/tool-result citation opt-ins are still not modeled.
539
539
  ¹³ Converse `requestMetadata` only accepts string key/value pairs. This serializer stringifies scalar metadata values and silently omits nested arrays or objects.
540
540
 
541
541
  ### Features Not Accessible Through Open Responses
@@ -617,7 +617,7 @@ Request-side mappings worth calling out:
617
617
  | `InputFile` with data URL (+ optional `filename`/`media_type`) | `{"type": "file", "file": {"filename": ..., "file_data": "data:<media_type>;base64,..."}}` |
618
618
  | `InputImage` with data URL (+ `media_type`) | `{"type": "image_url", "image_url": {"url": "data:<media_type>;base64,..."}}` |
619
619
 
620
- Unsupported Chat Completions request features not exposed by this gem include audio input/output (`audio`, `modalities`, `input_audio` content), `web_search_options`, custom tools, `tool_choice: {"type": "allowed_tools", ...}`, `seed`, `stop`, `logit_bias`, `n`, `prediction`, 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`).
620
+ Chat Completions request features with no canonical Open Responses field are available through `session.extra` (`stop`, `seed`, `logit_bias`, `n`, `prediction`, `web_search_options`, `modalities`, `audio`). Features not exposed at all include `input_audio` content blocks, custom tools, `tool_choice: {"type": "allowed_tools", ...}`, and the deprecated `functions`, `function_call`, `max_tokens`, and `user` fields. Use the modern canonical fields when available (`tools`, `tool_choice`, `max_output_tokens`, `safety_identifier`, `prompt_cache_key`).
621
621
 
622
622
  Response-side behavior and limitations:
623
623
 
@@ -676,7 +676,7 @@ Content and message restrictions:
676
676
  - Assistant messages map to `role: "model"`; consecutive same-role turns are merged automatically.
677
677
  - `InputImage`, `InputFile`, and `InputVideo` are only supported in user messages; the same content on an assistant message is omitted.
678
678
  - `InputImage` accepts any URL, a base64 data URL (with required `media_type`), or a Files API `file_id`.
679
- - `InputFile` accepts any URL, a base64 data URL, or `file_id`. `media_type` is required when not inferable from a `filename` or URL extension. Recognized extensions: `pdf`, `txt`, `md`/`markdown`, `html`/`htm`, `csv`, `json`, `xml`, `rtf`.
679
+ - `InputFile` accepts any URL, a base64 data URL, or `file_id`. `media_type` is required when not inferable from a `filename` or URL extension. Recognized extensions cover documents (`pdf`, `txt`, `md`/`markdown`, `html`/`htm`, `csv`, `json`, `xml`, `rtf`), images (`png`, `jpg`/`jpeg`, `webp`, `heic`, `heif`), audio (`mp3`, `wav`, `aiff`, `aac`, `ogg`, `flac`), and video (`mp4`, `mov`, `webm`, `mpeg`/`mpg`).
680
680
  - `InputVideo` requires a URL; raw bytes are not modeled.
681
681
  - `RefusalContent` is dropped silently so a parsed Chat Completions refusal can sit in session history without breaking subsequent serialization.
682
682
  - `Reasoning` items round-trip via `parts[].thought` with `thoughtSignature` preserved. `redacted_thinking`, `summary`, and unknown reasoning block types are silently skipped.
@@ -689,7 +689,7 @@ Response-side limitations:
689
689
 
690
690
  - Unknown response `Part` shapes (`inlineData`, `fileData`, `executableCode`, `codeExecutionResult`, `videoMetadata`, server-side `toolCall`/`toolResponse`, or any `Part` without a recognized content key) are silently skipped.
691
691
  - Only `candidates[0]` is parsed. When `candidateCount > 1`, additional candidates are dropped (their index is preserved on `provider_data`).
692
- - Function-call `id` from the response is dropped; the parser synthesizes `gemini_call_<seed>_<n>` so multiple calls in one response share a deterministic seed.
692
+ - Function-call `id` from the response is preserved when present; otherwise the parser synthesizes `gemini_call_<seed>_<n>` so multiple calls in one response share a deterministic seed.
693
693
  - `finishReason` mappings: `STOP` → `completed`; `MAX_TOKENS` → `incomplete`; `SAFETY`, `RECITATION`, `OTHER`, `BLOCKLIST`, `PROHIBITED_CONTENT`, `SPII`, `MALFORMED_FUNCTION_CALL`, `IMAGE_SAFETY`, `LANGUAGE`, `UNEXPECTED_TOOL_CALL`, `TOO_MANY_TOOL_CALLS`, `MODEL_ARMOR` → `failed`. `FINISH_REASON_UNSPECIFIED` is treated as nil.
694
694
  - An empty `candidates` array combined with `promptFeedback.blockReason` is mapped to `failed`.
695
695
  - `usageMetadata.cachedContentTokenCount`, `toolUsePromptTokenCount`, `promptTokensDetails`, `cacheTokensDetails`, and `toolUsePromptTokensDetails` populate `response.usage.input_tokens_details`. `thoughtsTokenCount` and `candidatesTokensDetails` populate `response.usage.output_tokens_details`.
@@ -725,14 +725,15 @@ Content and message restrictions:
725
725
  - `FunctionCall.arguments` must parse to a JSON object; non-object JSON values raise (Bedrock's `toolUse.input` requires an object).
726
726
  - `FunctionCallOutput.output` content supports `InputText`/`OutputText`, `InputImage`, `InputFile`, and `InputVideo`, mapping to Converse `text`, `image`, `document`, and `video` tool result blocks. Converse also supports `json` and `searchResult` tool result blocks, but this gem has no canonical content types for them, so unsupported content is omitted. `FunctionCallOutput.status` is mapped: `completed` → `success`, `failed`/`incomplete` → `error`, anything else passes through or is dropped.
727
727
  - `tool_choice: "none"` and `tool_choice` without registered tools are both omitted.
728
- - Converse API request features not exposed by this gem include `stopSequences`, `additionalModelRequestFields`, `additionalModelResponseFieldPaths`, `guardrailConfig` / `guardContent`, `cachePoint`, Prompt Management `promptVariables`, audio blocks, `searchResult` blocks, and `performanceConfig.latency`.
728
+ - Converse API request features with no canonical Open Responses field are available through `session.extra` (`stop_sequences`, `guardrail_config`, `additional_model_request_fields`, `additional_model_response_field_paths`, `performance_config`, `prompt_variables`) and content `extra` (`cache_point`). Features not exposed at all include `guardContent`, audio blocks, and `searchResult` blocks.
729
+ - The serialized payload includes `modelId` as a convenience; the Converse REST endpoint takes the model id in the URL path (`/model/{modelId}/converse`), and AWS REST-JSON services ignore unrecognized body members. Remove it from the body if you prefer a strictly minimal payload.
729
730
 
730
731
  Response-side limitations:
731
732
 
732
733
  - Unknown content block keys (e.g. `citationsContent`, `guardContent`) are silently skipped.
733
734
  - `metrics.latencyMs`, `trace` (guardrail and prompt-router trace events), `additionalModelResponseFields`, `performanceConfig`, and the raw `serviceTier` object are exposed on `response.provider_data`. `response.service_tier` is also populated from `serviceTier.type`.
734
- - `stopReason` mappings: `end_turn` / `tool_use` / `stop_sequence` → `completed`, `max_tokens` / `model_context_window_exceeded` → `incomplete`, `guardrail_intervened` / `content_filtered` / `malformed_model_output` / `malformed_tool_use` → `failed`. Unlike the Messages serializer, the matched stop sequence text is not surfaced separately because Converse does not echo it back unless you request provider-specific fields through `additionalModelResponseFieldPaths`, which this gem cannot emit.
735
- - `usage.cacheReadInputTokens` and `usage.cacheWriteInputTokens` populate `response.usage.input_tokens_details["cached_tokens"]` and `["cache_creation_input_tokens"]`. Cache writes still require `cachePoint` markers in the request, which this gem cannot produce.
735
+ - `stopReason` mappings: `end_turn` / `tool_use` / `stop_sequence` → `completed`, `max_tokens` / `model_context_window_exceeded` → `incomplete`, `guardrail_intervened` / `content_filtered` / `malformed_model_output` / `malformed_tool_use` → `failed`. Unlike the Messages serializer, the matched stop sequence text is not surfaced separately because Converse does not echo it back unless you request provider-specific fields through `additionalModelResponseFieldPaths` (available via the `additional_model_response_field_paths` session extra; the requested fields appear on `response.provider_data`).
736
+ - `usage.cacheReadInputTokens` and `usage.cacheWriteInputTokens` populate `response.usage.input_tokens_details["cached_tokens"]` and `["cache_creation_input_tokens"]`. Cache writes require `cachePoint` markers in the request, which are emitted via the `cache_point` content extra.
736
737
 
737
738
  ## Installation
738
739
 
data/VERSION CHANGED
@@ -1 +1 @@
1
- 0.1.0
1
+ 0.1.2
@@ -15,4 +15,7 @@ module PromptBuilder
15
15
 
16
16
  # Raised when an operation is invalid for the current session state.
17
17
  class InvalidStateError < Error; end
18
+
19
+ # Raised when a response payload does not match the expected shape.
20
+ class UnexpectedPayloadError < Error; end
18
21
  end
@@ -125,6 +125,14 @@ module PromptBuilder
125
125
  FIELDS.each { |f| attr_reader f }
126
126
  attr_reader :text_config, :output, :usage, :extra
127
127
 
128
+ # @!method provider_data
129
+ # Provider-specific response metadata with no canonical Open Responses
130
+ # slot (e.g. Gemini grounding metadata, Chat Completions system_fingerprint).
131
+ # Alias for +extra+.
132
+ #
133
+ # @return [Hash, nil]
134
+ alias_method :provider_data, :extra
135
+
128
136
  BOOLEAN_FIELDS.each { |f| alias_method("#{f}?", f) }
129
137
 
130
138
  # Create a new Response.
@@ -31,6 +31,22 @@ module PromptBuilder
31
31
  def deserialize_response(_hash)
32
32
  raise NotImplementedError
33
33
  end
34
+
35
+ # Ensure a response payload is a Hash containing the key that identifies
36
+ # it as a well-formed response for this format. Raised before parsing so
37
+ # malformed bodies (e.g. provider error envelopes) fail loudly with the
38
+ # offending body rather than producing a silently empty Response.
39
+ #
40
+ # @param hash [Object] the raw response payload
41
+ # @param key [String] a key that must be present in a valid payload
42
+ # @raise [UnexpectedPayloadError] if the key is missing
43
+ def require_response_key!(hash, key)
44
+ return if hash.is_a?(Hash) && hash.key?(key)
45
+
46
+ body = JSON.generate(hash)[0..200]
47
+ raise UnexpectedPayloadError,
48
+ "unexpected response payload, missing #{key.inspect}: #{body}"
49
+ end
34
50
  end
35
51
  end
36
52
  end
@@ -228,12 +228,16 @@ module PromptBuilder
228
228
 
229
229
  # Only text content is supported in tool output; other content types
230
230
  # are silently omitted.
231
- output.filter_map do |content|
231
+ content = output.filter_map do |content|
232
232
  case content
233
233
  when Content::InputText, Content::OutputText
234
234
  serialize_text_content(content)
235
235
  end
236
236
  end
237
+
238
+ # Chat Completions rejects tool messages with an empty content
239
+ # array; collapse to an empty string like the other serializers.
240
+ content.empty? ? "" : content
237
241
  end
238
242
 
239
243
  def flush_tool_calls!(messages, pending_tool_calls, last_assistant_msg = nil)
@@ -36,6 +36,10 @@ module PromptBuilder
36
36
  content: [Content::RefusalContent.new(refusal: message["refusal"])]
37
37
  )
38
38
  elsif message["content"].is_a?(Array)
39
+ # logprobs and annotations are reported once per message, so
40
+ # attach them to the first text block only to avoid duplicating
41
+ # them across blocks on round-trip.
42
+ first_text_block = true
39
43
  contents = message["content"].each_with_object([]) do |block, acc|
40
44
  case block["type"]
41
45
  when "text", nil
@@ -44,9 +48,10 @@ module PromptBuilder
44
48
 
45
49
  acc << Content::OutputText.new(
46
50
  text: text,
47
- logprobs: logprobs_content,
48
- annotations: annotations
51
+ logprobs: first_text_block ? logprobs_content : [],
52
+ annotations: first_text_block ? annotations : []
49
53
  )
54
+ first_text_block = false
50
55
  when "refusal"
51
56
  acc << Content::RefusalContent.new(refusal: block["refusal"]) if block["refusal"]
52
57
  else
@@ -109,6 +114,8 @@ module PromptBuilder
109
114
  if hash["object"] == "chat.completion.chunk"
110
115
  raise UnsupportedFormatError, "Chat Completions streaming chunks are not supported"
111
116
  end
117
+ require_response_key!(hash, "choices")
118
+ require_response_key!(hash, "model")
112
119
  # Responses with multiple choices have no canonical multi-candidate
113
120
  # representation; only the first choice is parsed (handled by the
114
121
  # caller using choices[0]).
@@ -55,15 +55,17 @@ module PromptBuilder
55
55
  # === Features in Converse not available through Open Responses
56
56
  #
57
57
  # The following Converse parameters cannot be set through the Open Responses
58
- # canonical format:
59
- # - +stopSequences+ — custom stop sequences
60
- # - Guardrail policies (+guardrailConfig+)
61
- # - Per-model passthrough fields (+additionalModelRequestFields+)
62
- # - Requested provider response fields (+additionalModelResponseFieldPaths+)
63
- # - Cross-region routing via inference profiles
64
- # - +performanceConfig+ latency settings beyond +serviceTier+
65
- # - Prompt management variables (+promptVariables+)
66
- # - Prompt caching markers (+cachePoint+)
58
+ # canonical format but are recognized via session +extra+ keys:
59
+ # - +stopSequences+ — custom stop sequences (+stop_sequences+)
60
+ # - Guardrail policies (+guardrail_config+)
61
+ # - Per-model passthrough fields (+additional_model_request_fields+)
62
+ # - Requested provider response fields (+additional_model_response_field_paths+)
63
+ # - +performanceConfig+ latency settings (+performance_config+)
64
+ # - Prompt management variables (+prompt_variables+)
65
+ #
66
+ # Prompt caching markers (+cachePoint+) are emitted via the +cache_point+
67
+ # content extra. Cross-region routing via inference profiles is selected
68
+ # through the model id and needs no request field.
67
69
  class Request < Base
68
70
  IMAGE_MEDIA_TYPE_FORMATS = {
69
71
  "image/jpeg" => "jpeg",
@@ -11,6 +11,9 @@ module PromptBuilder
11
11
  private
12
12
 
13
13
  def deserialize_response(hash)
14
+ require_response_key!(hash, "output")
15
+ require_response_key!(hash, "stopReason")
16
+
14
17
  usage_hash = hash["usage"]
15
18
  usage = if usage_hash
16
19
  cache_read = usage_hash["cacheReadInputTokens"]
@@ -64,6 +64,9 @@ module PromptBuilder
64
64
  private
65
65
 
66
66
  def deserialize_response(hash)
67
+ require_response_key!(hash, "candidates")
68
+ require_response_key!(hash, "modelVersion")
69
+
67
70
  usage = build_usage(hash["usageMetadata"])
68
71
 
69
72
  # Only the first candidate is parsed; additional candidates have no
@@ -71,12 +71,22 @@ module PromptBuilder
71
71
  # - +anthropic-beta+ headers and API versioning (this gem produces no HTTP
72
72
  # request — set headers in your HTTP client)
73
73
  class Request < Base
74
- DEFAULT_MAX_TOKENS = 4096
75
74
  SUPPORTED_METADATA_KEYS = ["user_id"].freeze
76
75
  EFFORT_LEVELS = ["low", "medium", "high", "xhigh", "max"].freeze
77
76
  SUPPORTED_THINKING_TYPES = ["adaptive", "disabled", "enabled"].freeze
78
77
  SUPPORTED_TOOL_CHOICE_TYPES = ["any", "auto", "none", "tool"].freeze
79
78
 
79
+ # Citation types accepted by the Messages API on text blocks. Annotations
80
+ # parsed from other providers (e.g. Chat Completions url_citation) have
81
+ # no Anthropic equivalent and are silently dropped.
82
+ SUPPORTED_CITATION_TYPES = [
83
+ "char_location",
84
+ "content_block_location",
85
+ "page_location",
86
+ "search_result_location",
87
+ "web_search_result_location"
88
+ ].freeze
89
+
80
90
  class << self
81
91
  private
82
92
 
@@ -85,7 +95,7 @@ module PromptBuilder
85
95
  raise UnsupportedFormatError, "Messages format requires session.model" unless session.model
86
96
 
87
97
  h["model"] = session.model
88
- h["max_tokens"] = session.max_output_tokens || DEFAULT_MAX_TOKENS
98
+ h["max_tokens"] = session.max_output_tokens if session.max_output_tokens
89
99
  h["temperature"] = session.temperature if session.temperature
90
100
  h["top_p"] = session.top_p if session.top_p
91
101
  effective_metadata = build_effective_metadata(session)
@@ -341,7 +351,8 @@ module PromptBuilder
341
351
  {"type" => "text", "text" => content.text}
342
352
  when Content::OutputText
343
353
  text = {"type" => "text", "text" => content.text}
344
- text["citations"] = content.annotations unless content.annotations.empty?
354
+ citations = serialize_citations(content.annotations)
355
+ text["citations"] = citations unless citations.empty?
345
356
  text
346
357
  when Content::InputImage
347
358
  # Assistant image content is not supported; omit it.
@@ -425,6 +436,16 @@ module PromptBuilder
425
436
  end
426
437
  end
427
438
 
439
+ # Only annotations that are valid Anthropic citation objects can be
440
+ # replayed as citations. Annotations from other providers (e.g. a
441
+ # Chat Completions url_citation) are silently dropped so a
442
+ # cross-provider session history doesn't produce an invalid request.
443
+ def serialize_citations(annotations)
444
+ annotations.select do |annotation|
445
+ annotation.is_a?(Hash) && SUPPORTED_CITATION_TYPES.include?(annotation["type"])
446
+ end
447
+ end
448
+
428
449
  # FunctionCallOutput.status values that map to tool_result.is_error.
429
450
  # "incomplete" and "failed" cover the OR canonical statuses; "error"
430
451
  # is accepted as a convenience alias.
@@ -11,6 +11,9 @@ module PromptBuilder
11
11
  private
12
12
 
13
13
  def deserialize_response(hash)
14
+ require_response_key!(hash, "content")
15
+ require_response_key!(hash, "stop_reason")
16
+
14
17
  usage_hash = hash["usage"]
15
18
  usage = build_usage(usage_hash) if usage_hash
16
19
 
@@ -5,6 +5,15 @@ module PromptBuilder
5
5
  class OpenResponses < Base
6
6
  # Request serializer for the OpenAI Open Responses API format.
7
7
  class Request < Base
8
+ # Content extra keys that are part of the Open Responses API schema and
9
+ # must survive extra-stripping (the canonical content classes have no
10
+ # dedicated attribute for them).
11
+ CANONICAL_EXTRA_KEYS = {
12
+ "input_file" => ["file_id"].freeze,
13
+ "input_image" => ["file_id"].freeze
14
+ }.freeze
15
+ private_constant :CANONICAL_EXTRA_KEYS
16
+
8
17
  class << self
9
18
  # Export a session to Open Responses API request payload.
10
19
  #
@@ -12,9 +21,9 @@ module PromptBuilder
12
21
  # @return [Hash] the serialized request payload
13
22
  def request_payload(session)
14
23
  payload = session.to_h
15
- apply_server_state!(payload, session)
24
+ items = apply_server_state!(payload, session)
16
25
  payload.delete("extra")
17
- strip_extra(payload)
26
+ strip_extra(payload, items, session.tool_definitions)
18
27
  normalize_content_urls!(payload)
19
28
  strip_non_replayable_reasoning!(payload)
20
29
  strip_output_only_fields!(payload)
@@ -28,36 +37,78 @@ module PromptBuilder
28
37
  # When the session is in server-state mode, replace the full input
29
38
  # array with only items added after the last response boundary and
30
39
  # include previous_response_id for the API to resolve history.
40
+ # Returns the item objects backing the payload's input array.
31
41
  def apply_server_state!(payload, session)
32
- return if session.local_state?
42
+ return session.items if session.local_state?
33
43
 
34
44
  payload.delete("input")
35
- new_items = session.items[session.response_boundary_index..]
36
- payload["input"] = new_items.map(&:to_h) if new_items && !new_items.empty?
45
+ new_items = session.items[session.response_boundary_index..] || []
46
+ payload["input"] = new_items.map(&:to_h) unless new_items.empty?
47
+ new_items
37
48
  end
38
49
 
39
- # Walk the payload and remove any "extra" keys from items, content
40
- # blocks, and tool definitions since they are not part of the Open
41
- # Responses API schema.
42
- def strip_extra(payload)
50
+ # Remove provider-specific extra data from items, content blocks, and
51
+ # tool definitions since it is not part of the Open Responses API
52
+ # schema. Items, content blocks, and tool definitions serialize their
53
+ # extras flat into the hash, so the extra keys must be looked up on
54
+ # the backing objects rather than removed as a nested "extra" key.
55
+ def strip_extra(payload, items, tool_definitions)
43
56
  input = payload["input"]
44
57
  if input.is_a?(Array)
45
- input.each do |item|
46
- next unless item.is_a?(Hash)
58
+ input.each_with_index do |item_hash, index|
59
+ next unless item_hash.is_a?(Hash)
60
+
61
+ item = items[index]
62
+ next unless item
47
63
 
48
- item.delete("extra")
64
+ strip_extra_keys!(item_hash, item.extra)
49
65
 
50
- if item["type"] == "function_call_output" && item["output"].is_a?(Array)
51
- strip_extra_from_blocks!(item["output"])
52
- else
53
- content = item["content"]
54
- strip_extra_from_blocks!(content) if content.is_a?(Array)
66
+ case item
67
+ when Items::Message
68
+ strip_extra_from_blocks!(item_hash["content"], item.content)
69
+ when Items::FunctionCallOutput
70
+ strip_extra_from_blocks!(item_hash["output"], item.output) if item.output.is_a?(Array)
55
71
  end
56
72
  end
57
73
  end
58
74
 
59
75
  tools = payload["tools"]
60
- strip_extra_from_blocks!(tools) if tools.is_a?(Array)
76
+ if tools.is_a?(Array)
77
+ tools.each_with_index do |tool_hash, index|
78
+ next unless tool_hash.is_a?(Hash)
79
+
80
+ definition = tool_definitions[index]
81
+ strip_extra_keys!(tool_hash, definition.extra) if definition
82
+ end
83
+ end
84
+ end
85
+
86
+ def strip_extra_from_blocks!(blocks, contents)
87
+ return unless blocks.is_a?(Array)
88
+
89
+ blocks.each_with_index do |block, index|
90
+ next unless block.is_a?(Hash)
91
+
92
+ content = contents[index]
93
+ next unless content.respond_to?(:extra)
94
+
95
+ keep = CANONICAL_EXTRA_KEYS.fetch(block["type"], [])
96
+ strip_extra_keys!(block, content.extra, keep: keep)
97
+ end
98
+ end
99
+
100
+ def strip_extra_keys!(hash, extra, keep: [])
101
+ return unless extra.is_a?(Hash)
102
+
103
+ extra.each_key do |key|
104
+ # "type" is canonical on every item and content block; an extra
105
+ # named "type" never survives serialization, so deleting it here
106
+ # would corrupt the block.
107
+ next if key == "type"
108
+ next if keep.include?(key)
109
+
110
+ hash.delete(key)
111
+ end
61
112
  end
62
113
 
63
114
  # Convert canonical "url" keys back to Open Responses API keys:
@@ -201,14 +252,6 @@ module PromptBuilder
201
252
  normalized.empty? ? nil : normalized
202
253
  end
203
254
 
204
- def strip_extra_from_blocks!(blocks)
205
- blocks.each do |block|
206
- next unless block.is_a?(Hash)
207
-
208
- block.delete("extra")
209
- end
210
- end
211
-
212
255
  # Normalize text.format for the Responses API. If the format uses
213
256
  # the Chat Completions nested json_schema sub-object, flatten it
214
257
  # so name/schema/strict/description sit directly under format.
@@ -9,6 +9,9 @@ module PromptBuilder
9
9
  private
10
10
 
11
11
  def deserialize_response(hash)
12
+ require_response_key!(hash, "status")
13
+ require_response_key!(hash, "object")
14
+
12
15
  PromptBuilder::Response.from_h(hash)
13
16
  end
14
17
  end
@@ -210,10 +210,10 @@ module PromptBuilder
210
210
  # Add a tool call output to the conversation.
211
211
  #
212
212
  # @param call_id [String] the tool call identifier
213
- # @param result [String, Hash, Array, Content::Base, nil] the tool call result
214
- # @return [Items::FunctionOutput] the added function output item
213
+ # @param result [String, Array<Content::Base, Hash>, nil] the tool call result
214
+ # @return [Items::FunctionCallOutput] the added function call output item
215
215
  def add_function_call_output(call_id:, result:)
216
- add_item(Items::FunctionOutput.new(call_id: call_id, result: result))
216
+ add_item(Items::FunctionCallOutput.new(call_id: call_id, output: result))
217
217
  end
218
218
 
219
219
  # Add a raw item to the conversation.
@@ -44,14 +44,16 @@ module PromptBuilder
44
44
  "data:#{content_type};base64,#{[data].pack("m0")}"
45
45
  end
46
46
 
47
- # Parse a data URL into its media type and base64-encoded data.
47
+ # Parse a data URL into its media type and base64-encoded data. Media type
48
+ # parameters (e.g. "data:text/plain;charset=utf-8;base64,...") are allowed
49
+ # and excluded from the returned media type.
48
50
  #
49
51
  # @param url [String, nil] a URL that may be a data URL
50
52
  # @return [Array(String, String), nil] a two-element array of +[media_type, data]+
51
- # or nil if the URL is not a data URL
53
+ # or nil if the URL is not a base64-encoded data URL
52
54
  def parse_data_url(url)
53
55
  return nil unless url
54
- match = url.match(/\Adata:([^;]+);base64,(.*)\z/m)
56
+ match = url.match(/\Adata:([^;,]+)(?:;[^,]*)?;base64,(.*)\z/m)
55
57
  return nil unless match
56
58
  [match[1], match[2]]
57
59
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: prompt_builder
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.1.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Brian Durand