prompt_builder 0.1.1 → 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: 2572a48b69686a8b061f84fd174dfc164adbfe0c613894bfcbb26fd0039d5108
4
- data.tar.gz: c5382987c1d85d31b6cef3f6bb90cc1e4491d1e27d1dd2587d08f2d099360c4f
3
+ metadata.gz: e762383ea51918fa77b5962f0137775551a37da1c228a37d24364011c8d21f73
4
+ data.tar.gz: 64e2a4e22feb0a8e0e5752f4807b79452c3cb700dd8b5740500f11cbe0b4217e
5
5
  SHA512:
6
- metadata.gz: 75ac02437e15581cf45c13c592605b6434c0634141bda6d86324e87bb65c59502e1de00f7459c49fb6b3f432a8a2ec2c5487eab4f73de1609c26625bdb37c510
7
- data.tar.gz: 3c39ea80cbdb488a63926357601ce47b97cd5940c8ee95742b31b4df30e4b4519cf98dec931e11929ddb80569a0a469736a37aa6337af44dc53092f433051714
6
+ metadata.gz: b7eb3a4806f9cd7a24e00652659b7a5d641ae5877187bd714afd0e4eaa86c7a7901eebcfdf11202497d0b98cce03f9a0fec10f1840883f91b4b87be3a9f2c387
7
+ data.tar.gz: 2b731def4d25b2233b208d3fb3d8867ee17cce9356cb84b3d57d3d3043116af5eb55ca7888de00c329c3637b209bbc70ea11dada040bc5f97f9701c85a0fa8ee
data/CHANGELOG.md CHANGED
@@ -4,6 +4,21 @@ 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.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
+
7
22
  ## 0.1.1
8
23
 
9
24
  ### Changed
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.1
1
+ 0.1.2
@@ -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.
@@ -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
@@ -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",
@@ -76,6 +76,17 @@ module PromptBuilder
76
76
  SUPPORTED_THINKING_TYPES = ["adaptive", "disabled", "enabled"].freeze
77
77
  SUPPORTED_TOOL_CHOICE_TYPES = ["any", "auto", "none", "tool"].freeze
78
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
+
79
90
  class << self
80
91
  private
81
92
 
@@ -340,7 +351,8 @@ module PromptBuilder
340
351
  {"type" => "text", "text" => content.text}
341
352
  when Content::OutputText
342
353
  text = {"type" => "text", "text" => content.text}
343
- text["citations"] = content.annotations unless content.annotations.empty?
354
+ citations = serialize_citations(content.annotations)
355
+ text["citations"] = citations unless citations.empty?
344
356
  text
345
357
  when Content::InputImage
346
358
  # Assistant image content is not supported; omit it.
@@ -424,6 +436,16 @@ module PromptBuilder
424
436
  end
425
437
  end
426
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
+
427
449
  # FunctionCallOutput.status values that map to tool_result.is_error.
428
450
  # "incomplete" and "failed" cover the OR canonical statuses; "error"
429
451
  # is accepted as a convenience alias.
@@ -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.
@@ -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.1
4
+ version: 0.1.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Brian Durand