prompt_builder 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +24 -0
  3. data/MIT-LICENSE +20 -0
  4. data/README.md +763 -0
  5. data/VERSION +1 -0
  6. data/lib/prompt_builder/content/base.rb +44 -0
  7. data/lib/prompt_builder/content/input_file.rb +63 -0
  8. data/lib/prompt_builder/content/input_image.rb +64 -0
  9. data/lib/prompt_builder/content/input_text.rb +42 -0
  10. data/lib/prompt_builder/content/input_video.rb +43 -0
  11. data/lib/prompt_builder/content/output_text.rb +59 -0
  12. data/lib/prompt_builder/content/reasoning_text.rb +42 -0
  13. data/lib/prompt_builder/content/refusal_content.rb +42 -0
  14. data/lib/prompt_builder/content/summary_text.rb +42 -0
  15. data/lib/prompt_builder/content/text.rb +42 -0
  16. data/lib/prompt_builder/content.rb +28 -0
  17. data/lib/prompt_builder/errors.rb +18 -0
  18. data/lib/prompt_builder/items/base.rb +41 -0
  19. data/lib/prompt_builder/items/compaction.rb +60 -0
  20. data/lib/prompt_builder/items/function_call.rb +97 -0
  21. data/lib/prompt_builder/items/function_call_output.rb +110 -0
  22. data/lib/prompt_builder/items/item_reference.rb +42 -0
  23. data/lib/prompt_builder/items/message.rb +113 -0
  24. data/lib/prompt_builder/items/reasoning.rb +75 -0
  25. data/lib/prompt_builder/items.rb +13 -0
  26. data/lib/prompt_builder/response.rb +257 -0
  27. data/lib/prompt_builder/serializers/base.rb +37 -0
  28. data/lib/prompt_builder/serializers/chat_completion/request.rb +389 -0
  29. data/lib/prompt_builder/serializers/chat_completion/response.rb +139 -0
  30. data/lib/prompt_builder/serializers/chat_completion.rb +30 -0
  31. data/lib/prompt_builder/serializers/converse/request.rb +623 -0
  32. data/lib/prompt_builder/serializers/converse/response.rb +140 -0
  33. data/lib/prompt_builder/serializers/converse.rb +30 -0
  34. data/lib/prompt_builder/serializers/gemini/request.rb +562 -0
  35. data/lib/prompt_builder/serializers/gemini/response.rb +233 -0
  36. data/lib/prompt_builder/serializers/gemini.rb +30 -0
  37. data/lib/prompt_builder/serializers/messages/request.rb +634 -0
  38. data/lib/prompt_builder/serializers/messages/response.rb +157 -0
  39. data/lib/prompt_builder/serializers/messages.rb +30 -0
  40. data/lib/prompt_builder/serializers/open_responses/request.rb +229 -0
  41. data/lib/prompt_builder/serializers/open_responses/response.rb +18 -0
  42. data/lib/prompt_builder/serializers/open_responses.rb +30 -0
  43. data/lib/prompt_builder/serializers.rb +35 -0
  44. data/lib/prompt_builder/session.rb +383 -0
  45. data/lib/prompt_builder/tool_registry.rb +75 -0
  46. data/lib/prompt_builder/tools/definition.rb +66 -0
  47. data/lib/prompt_builder/tools.rb +7 -0
  48. data/lib/prompt_builder/usage.rb +100 -0
  49. data/lib/prompt_builder.rb +86 -0
  50. data/prompt_builder.gemspec +41 -0
  51. metadata +107 -0
@@ -0,0 +1,157 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module PromptBuilder
6
+ module Serializers
7
+ class Messages < Base
8
+ # Response parser for the Anthropic Messages API format.
9
+ class Response < Base
10
+ class << self
11
+ private
12
+
13
+ def deserialize_response(hash)
14
+ usage_hash = hash["usage"]
15
+ usage = build_usage(usage_hash) if usage_hash
16
+
17
+ PromptBuilder::Response.new(
18
+ id: hash["id"],
19
+ object: hash["type"],
20
+ model: hash["model"],
21
+ output: build_output_items(hash["content"] || []),
22
+ status: map_stop_reason(hash["stop_reason"]),
23
+ incomplete_details: build_incomplete_details(hash),
24
+ usage: usage
25
+ )
26
+ end
27
+
28
+ def build_usage(usage_hash)
29
+ input_tokens_details = usage_hash["input_tokens_details"] || {}
30
+ cache_creation = usage_hash["cache_creation_input_tokens"]
31
+ cache_read = usage_hash["cache_read_input_tokens"]
32
+ cache_creation_breakdown = usage_hash["cache_creation"]
33
+ service_tier = usage_hash["service_tier"]
34
+ inference_geo = usage_hash["inference_geo"]
35
+ server_tool_use = usage_hash["server_tool_use"]
36
+
37
+ input_tokens_details = input_tokens_details.merge("cache_creation_input_tokens" => cache_creation) if cache_creation
38
+ input_tokens_details = input_tokens_details.merge("cached_tokens" => cache_read) if cache_read
39
+ input_tokens_details = input_tokens_details.merge("cache_creation" => cache_creation_breakdown) if cache_creation_breakdown
40
+ input_tokens_details = input_tokens_details.merge("service_tier" => service_tier) if service_tier
41
+ input_tokens_details = input_tokens_details.merge("inference_geo" => inference_geo) if inference_geo
42
+ input_tokens_details = input_tokens_details.merge("server_tool_use" => server_tool_use) if server_tool_use
43
+
44
+ # Anthropic reports input_tokens excluding cached/cache-creation tokens,
45
+ # which are billed and counted separately. Include them in the total.
46
+ total = usage_hash["input_tokens"].to_i + usage_hash["output_tokens"].to_i +
47
+ cache_creation.to_i + cache_read.to_i
48
+
49
+ Usage.new(
50
+ input_tokens: usage_hash["input_tokens"],
51
+ output_tokens: usage_hash["output_tokens"],
52
+ total_tokens: total,
53
+ input_tokens_details: input_tokens_details.empty? ? nil : input_tokens_details,
54
+ output_tokens_details: usage_hash["output_tokens_details"]
55
+ )
56
+ end
57
+
58
+ def build_incomplete_details(hash)
59
+ details = {}
60
+ details["stop_sequence"] = hash["stop_sequence"] if hash["stop_sequence"]
61
+ details["stop_details"] = hash["stop_details"] if hash["stop_details"]
62
+ details["container"] = hash["container"] if hash["container"]
63
+ details.empty? ? nil : details
64
+ end
65
+
66
+ # Anthropic stop_reason values:
67
+ # - end_turn, tool_use, stop_sequence, pause_turn → completed
68
+ # - max_tokens → incomplete (truncated mid-output)
69
+ # - refusal → failed (model declined to respond)
70
+ # - anything else → pass through unchanged so callers can inspect it
71
+ def map_stop_reason(reason)
72
+ case reason
73
+ when "end_turn", "tool_use", "stop_sequence", "pause_turn"
74
+ "completed"
75
+ when "max_tokens"
76
+ "incomplete"
77
+ when "refusal"
78
+ "failed"
79
+ else
80
+ reason
81
+ end
82
+ end
83
+
84
+ def build_output_items(content_blocks)
85
+ output = []
86
+ text_contents = []
87
+ reasoning_contents = []
88
+
89
+ content_blocks.each do |block|
90
+ type = block["type"]
91
+ case type
92
+ when "text"
93
+ flush_reasoning_contents!(output, reasoning_contents)
94
+ text_contents << Content::OutputText.new(
95
+ text: block["text"],
96
+ annotations: block["citations"] || []
97
+ )
98
+ when "tool_use"
99
+ flush_text_contents!(output, text_contents)
100
+ flush_reasoning_contents!(output, reasoning_contents)
101
+ output << Items::FunctionCall.new(
102
+ name: block["name"],
103
+ call_id: block["id"],
104
+ arguments: JSON.generate(block["input"] || {})
105
+ )
106
+ when "thinking", "redacted_thinking"
107
+ flush_text_contents!(output, text_contents)
108
+ reasoning_contents << deserialize_reasoning_block(block)
109
+ else
110
+ # Unsupported content block types (e.g. built-in tool results)
111
+ # are silently skipped rather than raising.
112
+ next
113
+ end
114
+ end
115
+
116
+ flush_text_contents!(output, text_contents)
117
+ flush_reasoning_contents!(output, reasoning_contents)
118
+ output
119
+ end
120
+
121
+ def deserialize_reasoning_block(block)
122
+ case block["type"]
123
+ when "thinking"
124
+ {
125
+ "type" => "thinking",
126
+ "thinking" => block.fetch("thinking", ""),
127
+ "signature" => block["signature"]
128
+ }
129
+ when "redacted_thinking"
130
+ {
131
+ "type" => "redacted_thinking",
132
+ "data" => block["data"]
133
+ }
134
+ end
135
+ end
136
+
137
+ def flush_text_contents!(output, text_contents)
138
+ return if text_contents.empty?
139
+
140
+ output << Items::Message.new(
141
+ role: "assistant",
142
+ content: text_contents.dup
143
+ )
144
+ text_contents.clear
145
+ end
146
+
147
+ def flush_reasoning_contents!(output, reasoning_contents)
148
+ return if reasoning_contents.empty?
149
+
150
+ output << Items::Reasoning.new(content: reasoning_contents.dup)
151
+ reasoning_contents.clear
152
+ end
153
+ end
154
+ end
155
+ end
156
+ end
157
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PromptBuilder
4
+ module Serializers
5
+ # Serializer for the Anthropic Messages API format.
6
+ # Delegates request and response handling to dedicated nested classes.
7
+ class Messages < Base
8
+ autoload :Request, File.expand_path("messages/request", __dir__)
9
+ autoload :Response, File.expand_path("messages/response", __dir__)
10
+
11
+ class << self
12
+ # Export a session to Messages request payload.
13
+ #
14
+ # @param session [Session] the session to export
15
+ # @return [Hash] the serialized request payload
16
+ def request_payload(session)
17
+ Request.request_payload(session)
18
+ end
19
+
20
+ # Parse a Messages response into an PromptBuilder::Response.
21
+ #
22
+ # @param hash [Hash] the response hash in Messages format
23
+ # @return [PromptBuilder::Response] the parsed response
24
+ def parse_response(hash)
25
+ Response.parse_response(hash)
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,229 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PromptBuilder
4
+ module Serializers
5
+ class OpenResponses < Base
6
+ # Request serializer for the OpenAI Open Responses API format.
7
+ class Request < Base
8
+ class << self
9
+ # Export a session to Open Responses API request payload.
10
+ #
11
+ # @param session [Session] the session to export
12
+ # @return [Hash] the serialized request payload
13
+ def request_payload(session)
14
+ payload = session.to_h
15
+ apply_server_state!(payload, session)
16
+ payload.delete("extra")
17
+ strip_extra(payload)
18
+ normalize_content_urls!(payload)
19
+ strip_non_replayable_reasoning!(payload)
20
+ strip_output_only_fields!(payload)
21
+ normalize_and_validate_input_images!(payload)
22
+ normalize_text_format!(payload)
23
+ payload
24
+ end
25
+
26
+ private
27
+
28
+ # When the session is in server-state mode, replace the full input
29
+ # array with only items added after the last response boundary and
30
+ # include previous_response_id for the API to resolve history.
31
+ def apply_server_state!(payload, session)
32
+ return if session.local_state?
33
+
34
+ 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?
37
+ end
38
+
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)
43
+ input = payload["input"]
44
+ if input.is_a?(Array)
45
+ input.each do |item|
46
+ next unless item.is_a?(Hash)
47
+
48
+ item.delete("extra")
49
+
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)
55
+ end
56
+ end
57
+ end
58
+
59
+ tools = payload["tools"]
60
+ strip_extra_from_blocks!(tools) if tools.is_a?(Array)
61
+ end
62
+
63
+ # Convert canonical "url" keys back to Open Responses API keys:
64
+ # input_image "url" -> "image_url"
65
+ # input_file "url" -> "file_url"/"file_data"
66
+ # input_video "url" -> "video_url"
67
+ def normalize_content_urls!(payload)
68
+ input = payload["input"]
69
+ return unless input.is_a?(Array)
70
+
71
+ input.each do |item|
72
+ next unless item.is_a?(Hash)
73
+
74
+ if item["type"] == "function_call_output" && item["output"].is_a?(Array)
75
+ normalize_url_keys_in_blocks!(item["output"])
76
+ next
77
+ end
78
+
79
+ content = item["content"]
80
+ normalize_url_keys_in_blocks!(content) if content.is_a?(Array)
81
+ end
82
+ end
83
+
84
+ def normalize_url_keys_in_blocks!(blocks)
85
+ blocks.each do |block|
86
+ next unless block.is_a?(Hash)
87
+
88
+ case block["type"]
89
+ when "input_image"
90
+ if block.key?("url")
91
+ block["image_url"] = block.delete("url")
92
+ end
93
+ when "input_file"
94
+ if block.key?("url")
95
+ url = block.delete("url")
96
+ if PromptBuilder.parse_data_url(url)
97
+ block["file_data"] = url
98
+ else
99
+ block["file_url"] = url
100
+ end
101
+ end
102
+ when "input_video"
103
+ if block.key?("url")
104
+ block["video_url"] = block.delete("url")
105
+ end
106
+ end
107
+ end
108
+ end
109
+
110
+ def normalize_and_validate_input_images!(payload)
111
+ input = payload["input"]
112
+ return unless input.is_a?(Array)
113
+
114
+ input.each_with_index do |item, input_index|
115
+ next unless item.is_a?(Hash)
116
+
117
+ if item["type"] == "function_call_output" && item["output"].is_a?(Array)
118
+ normalize_and_validate_image_blocks!(item["output"], "input.#{input_index}.output")
119
+ next
120
+ end
121
+
122
+ content = item["content"]
123
+ next unless content.is_a?(Array)
124
+
125
+ normalize_and_validate_image_blocks!(content, "input.#{input_index}.content")
126
+ end
127
+ end
128
+
129
+ def normalize_and_validate_image_blocks!(blocks, path_prefix)
130
+ blocks.each_with_index do |block, block_index|
131
+ next unless block.is_a?(Hash)
132
+ next unless block["type"] == "input_image"
133
+
134
+ image_url = normalize_optional_string(block["image_url"])
135
+ file_id = normalize_optional_string(block["file_id"])
136
+
137
+ image_url ? block["image_url"] = image_url : block.delete("image_url")
138
+ file_id ? block["file_id"] = file_id : block.delete("file_id")
139
+
140
+ if image_url && file_id
141
+ raise InvalidItemError,
142
+ "#{path_prefix}.#{block_index} includes both image_url and file_id; provide exactly one"
143
+ end
144
+
145
+ next if image_url || file_id
146
+
147
+ raise InvalidItemError,
148
+ "#{path_prefix}.#{block_index} requires exactly one of image_url or file_id"
149
+ end
150
+ end
151
+
152
+ # Remove reasoning items that cannot be sent back in input.
153
+ # The Responses API only accepts reasoning items with
154
+ # encrypted_content; plain reasoning_text content blocks are
155
+ # output-only and cause an invalid_union error.
156
+ # For preserved reasoning items, strip the content key since
157
+ # the input schema only accepts null for that field.
158
+ def strip_non_replayable_reasoning!(payload)
159
+ input = payload["input"]
160
+ return unless input.is_a?(Array)
161
+
162
+ input.reject! do |item|
163
+ item.is_a?(Hash) &&
164
+ item["type"] == "reasoning" &&
165
+ !item["encrypted_content"]
166
+ end
167
+
168
+ input.each do |item|
169
+ next unless item.is_a?(Hash) && item["type"] == "reasoning"
170
+
171
+ item.delete("content")
172
+ end
173
+ end
174
+
175
+ # Remove output-only fields from content blocks that are not
176
+ # accepted by the Responses API input schema.
177
+ # OutputTextContentParam only accepts type, text, and annotations;
178
+ # logprobs is output-only metadata.
179
+ def strip_output_only_fields!(payload)
180
+ input = payload["input"]
181
+ return unless input.is_a?(Array)
182
+
183
+ input.each do |item|
184
+ next unless item.is_a?(Hash)
185
+
186
+ content = item["content"]
187
+ next unless content.is_a?(Array)
188
+
189
+ content.each do |block|
190
+ next unless block.is_a?(Hash) && block["type"] == "output_text"
191
+
192
+ block.delete("logprobs")
193
+ end
194
+ end
195
+ end
196
+
197
+ def normalize_optional_string(value)
198
+ return value unless value.is_a?(String)
199
+
200
+ normalized = value.strip
201
+ normalized.empty? ? nil : normalized
202
+ end
203
+
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
+ # Normalize text.format for the Responses API. If the format uses
213
+ # the Chat Completions nested json_schema sub-object, flatten it
214
+ # so name/schema/strict/description sit directly under format.
215
+ def normalize_text_format!(payload)
216
+ format = payload.dig("text", "format")
217
+ return unless format.is_a?(Hash) && format["type"] == "json_schema"
218
+
219
+ json_schema = format["json_schema"]
220
+ return unless json_schema.is_a?(Hash)
221
+
222
+ format.delete("json_schema")
223
+ json_schema.each { |key, value| format[key] = value }
224
+ end
225
+ end
226
+ end
227
+ end
228
+ end
229
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PromptBuilder
4
+ module Serializers
5
+ class OpenResponses < Base
6
+ # Response parser for the OpenAI Open Responses API format.
7
+ class Response < Base
8
+ class << self
9
+ private
10
+
11
+ def deserialize_response(hash)
12
+ PromptBuilder::Response.from_h(hash)
13
+ end
14
+ end
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PromptBuilder
4
+ module Serializers
5
+ # Serializer for OpenAI Open Responses API format.
6
+ # Delegates request and response handling to dedicated nested classes.
7
+ class OpenResponses < Base
8
+ autoload :Request, File.expand_path("open_responses/request", __dir__)
9
+ autoload :Response, File.expand_path("open_responses/response", __dir__)
10
+
11
+ class << self
12
+ # Export a session to Open Responses API request payload.
13
+ #
14
+ # @param session [Session] the session to export
15
+ # @return [Hash] the serialized request payload
16
+ def request_payload(session)
17
+ Request.request_payload(session)
18
+ end
19
+
20
+ # Parse an Open Responses response into an PromptBuilder::Response.
21
+ #
22
+ # @param hash [Hash] the response hash in Open Responses format
23
+ # @return [PromptBuilder::Response] the parsed response
24
+ def parse_response(hash)
25
+ Response.parse_response(hash)
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PromptBuilder
4
+ module Serializers
5
+ autoload :Base, File.expand_path("serializers/base", __dir__)
6
+ autoload :ChatCompletion, File.expand_path("serializers/chat_completion", __dir__)
7
+ autoload :Converse, File.expand_path("serializers/converse", __dir__)
8
+ autoload :Gemini, File.expand_path("serializers/gemini", __dir__)
9
+ autoload :Messages, File.expand_path("serializers/messages", __dir__)
10
+ autoload :OpenResponses, File.expand_path("serializers/open_responses", __dir__)
11
+
12
+ # Mapping of shorthand symbols to serializer classes.
13
+ ALIASES = {
14
+ chat_completion: ChatCompletion,
15
+ converse: Converse,
16
+ gemini: Gemini,
17
+ messages: Messages,
18
+ open_responses: OpenResponses
19
+ }.freeze
20
+
21
+ # Resolve a serializer class from a symbol or class reference.
22
+ #
23
+ # @param serializer [Class, Symbol] a serializer class or a symbol shorthand
24
+ # (+:open_responses+, +:chat_completion+, +:messages+, +:gemini+, +:converse+)
25
+ # @return [Class] the resolved serializer class
26
+ # @raise [ArgumentError] if a symbol is given that does not map to a known serializer
27
+ def self.resolve(serializer)
28
+ return serializer unless serializer.is_a?(Symbol)
29
+
30
+ ALIASES.fetch(serializer) do
31
+ raise ArgumentError, "Unknown serializer: #{serializer.inspect}. Valid options: #{ALIASES.keys.map(&:inspect).join(", ")}"
32
+ end
33
+ end
34
+ end
35
+ end