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,233 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "securerandom"
5
+
6
+ module PromptBuilder
7
+ module Serializers
8
+ class Gemini < Base
9
+ # Response parser for the Google Gemini API format.
10
+ #
11
+ # === Response metadata not modeled by Open Responses
12
+ #
13
+ # The Gemini response surface has fields with no canonical Open Responses
14
+ # equivalent. The parser preserves them on +Response#extra+ rather
15
+ # than dropping them silently:
16
+ # - +groundingMetadata+ — web search grounding chunks, supports, queries
17
+ # - +citationMetadata+ — per-candidate citation sources
18
+ # - +urlContextMetadata+ / +urlRetrievalMetadata+ — URL retrieval traces
19
+ # - +safetyRatings+ — per-candidate safety probabilities and severities
20
+ # - +promptFeedback+ — prompt-level safety feedback
21
+ # - +avgLogprobs+ / +logprobsResult+ — log-probability outputs
22
+ # - +finishMessage+ — human-readable explanation of +finishReason+
23
+ # - +createTime+ — RFC 3339 response creation timestamp
24
+ # - +index+ — candidate index when +candidateCount+ > 1
25
+ #
26
+ # Additional +usageMetadata+ breakdowns (per-modality token counts and
27
+ # tool-use prompt tokens) are surfaced through +Usage#input_tokens_details+
28
+ # and +Usage#output_tokens_details+.
29
+ #
30
+ # === Unsupported response shapes
31
+ #
32
+ # The response parser silently skips +Part+ shapes that have no canonical
33
+ # Open Responses equivalent:
34
+ # - +inlineData+ / +fileData+ parts (image/audio output blobs)
35
+ # - +executableCode+ / +codeExecutionResult+ parts
36
+ # - server-side +toolCall+ / +toolResponse+ parts
37
+ # - +Part+s with no recognized content key
38
+ #
39
+ # When a response contains multiple candidates, only the first is parsed.
40
+ class Response < Base
41
+ FAILED_FINISH_REASONS = %w[
42
+ SAFETY
43
+ RECITATION
44
+ OTHER
45
+ BLOCKLIST
46
+ PROHIBITED_CONTENT
47
+ SPII
48
+ MALFORMED_FUNCTION_CALL
49
+ IMAGE_SAFETY
50
+ LANGUAGE
51
+ UNEXPECTED_TOOL_CALL
52
+ TOO_MANY_TOOL_CALLS
53
+ MODEL_ARMOR
54
+ IMAGE_PROHIBITED_CONTENT
55
+ IMAGE_OTHER
56
+ NO_IMAGE
57
+ IMAGE_RECITATION
58
+ MISSING_THOUGHT_SIGNATURE
59
+ MALFORMED_RESPONSE
60
+ ].freeze
61
+ private_constant :FAILED_FINISH_REASONS
62
+
63
+ class << self
64
+ private
65
+
66
+ def deserialize_response(hash)
67
+ usage = build_usage(hash["usageMetadata"])
68
+
69
+ # Only the first candidate is parsed; additional candidates have no
70
+ # canonical multi-candidate representation and are silently dropped.
71
+ candidates = hash["candidates"] || []
72
+ first_candidate = candidates[0]
73
+
74
+ response_id = hash["responseId"]
75
+ call_id_seed = response_id || SecureRandom.hex(4)
76
+
77
+ output_items = if first_candidate && first_candidate["content"]
78
+ build_output_items(first_candidate["content"]["parts"] || [], call_id_seed)
79
+ else
80
+ []
81
+ end
82
+
83
+ finish_reason = first_candidate ? first_candidate["finishReason"] : nil
84
+ status = map_finish_reason(finish_reason)
85
+
86
+ # When candidates is empty due to a prompt-level safety block, Gemini
87
+ # returns an empty `candidates` and a `promptFeedback.blockReason`.
88
+ # Surface that as a failed status rather than silently nil.
89
+ if status.nil? && candidates.empty? && hash.dig("promptFeedback", "blockReason")
90
+ status = "failed"
91
+ end
92
+
93
+ provider_data = build_provider_data(hash, first_candidate)
94
+
95
+ PromptBuilder::Response.new(
96
+ id: response_id,
97
+ object: nil,
98
+ model: hash["modelVersion"],
99
+ output: output_items,
100
+ status: status,
101
+ usage: usage,
102
+ extra: provider_data
103
+ )
104
+ end
105
+
106
+ def build_usage(usage_hash)
107
+ return nil unless usage_hash
108
+
109
+ input_tokens_details = {}
110
+ input_tokens_details["cached_tokens"] = usage_hash["cachedContentTokenCount"] if usage_hash["cachedContentTokenCount"]
111
+ input_tokens_details["tool_use_prompt_tokens"] = usage_hash["toolUsePromptTokenCount"] if usage_hash["toolUsePromptTokenCount"]
112
+ input_tokens_details["prompt_tokens_details"] = usage_hash["promptTokensDetails"] if usage_hash["promptTokensDetails"]
113
+ input_tokens_details["cache_tokens_details"] = usage_hash["cacheTokensDetails"] if usage_hash["cacheTokensDetails"]
114
+ input_tokens_details["tool_use_prompt_tokens_details"] = usage_hash["toolUsePromptTokensDetails"] if usage_hash["toolUsePromptTokensDetails"]
115
+
116
+ output_tokens_details = {}
117
+ output_tokens_details["reasoning_tokens"] = usage_hash["thoughtsTokenCount"] if usage_hash["thoughtsTokenCount"]
118
+ output_tokens_details["candidates_tokens_details"] = usage_hash["candidatesTokensDetails"] if usage_hash["candidatesTokensDetails"]
119
+
120
+ Usage.new(
121
+ input_tokens: usage_hash["promptTokenCount"],
122
+ output_tokens: usage_hash["candidatesTokenCount"],
123
+ total_tokens: usage_hash["totalTokenCount"],
124
+ input_tokens_details: input_tokens_details.empty? ? nil : input_tokens_details,
125
+ output_tokens_details: output_tokens_details.empty? ? nil : output_tokens_details
126
+ )
127
+ end
128
+
129
+ def build_provider_data(hash, candidate)
130
+ data = {}
131
+ data["create_time"] = hash["createTime"] if hash["createTime"]
132
+ data["model_status"] = hash["modelStatus"] if hash["modelStatus"]
133
+ data["prompt_feedback"] = hash["promptFeedback"] if hash["promptFeedback"]
134
+
135
+ if candidate
136
+ data["index"] = candidate["index"] if candidate.key?("index")
137
+ data["safety_ratings"] = candidate["safetyRatings"] if candidate["safetyRatings"]
138
+ data["citation_metadata"] = candidate["citationMetadata"] if candidate["citationMetadata"]
139
+ data["grounding_metadata"] = candidate["groundingMetadata"] if candidate["groundingMetadata"]
140
+ data["url_context_metadata"] = candidate["urlContextMetadata"] if candidate["urlContextMetadata"]
141
+ data["url_retrieval_metadata"] = candidate["urlRetrievalMetadata"] if candidate["urlRetrievalMetadata"]
142
+ data["avg_logprobs"] = candidate["avgLogprobs"] if candidate.key?("avgLogprobs")
143
+ data["logprobs_result"] = candidate["logprobsResult"] if candidate["logprobsResult"]
144
+ data["grounding_attributions"] = candidate["groundingAttributions"] if candidate["groundingAttributions"]
145
+ data["finish_message"] = candidate["finishMessage"] if candidate["finishMessage"]
146
+ end
147
+
148
+ data.empty? ? nil : data
149
+ end
150
+
151
+ def map_finish_reason(reason)
152
+ case reason
153
+ when nil, "FINISH_REASON_UNSPECIFIED"
154
+ nil
155
+ when "STOP"
156
+ "completed"
157
+ when "MAX_TOKENS"
158
+ "incomplete"
159
+ when *FAILED_FINISH_REASONS
160
+ "failed"
161
+ else
162
+ reason
163
+ end
164
+ end
165
+
166
+ def build_output_items(parts, call_id_seed)
167
+ output = []
168
+ text_contents = []
169
+ reasoning_contents = []
170
+ call_index = 0
171
+
172
+ parts.each do |part|
173
+ if part["thought"]
174
+ flush_text_contents!(output, text_contents)
175
+ block = {
176
+ "type" => "thinking",
177
+ "thinking" => part["text"] || ""
178
+ }
179
+ block["signature"] = part["thoughtSignature"] if part["thoughtSignature"]
180
+ reasoning_contents << block
181
+ elsif part["functionCall"]
182
+ flush_text_contents!(output, text_contents)
183
+ flush_reasoning_contents!(output, reasoning_contents)
184
+
185
+ function_call = part["functionCall"]
186
+ call_id = function_call["id"] || "gemini_call_#{call_id_seed}_#{call_index}"
187
+
188
+ output << Items::FunctionCall.new(
189
+ name: function_call["name"],
190
+ call_id: call_id,
191
+ arguments: JSON.generate(function_call["args"] || {}),
192
+ **(part["thoughtSignature"] ? {thought_signature: part["thoughtSignature"]} : {})
193
+ )
194
+ call_index += 1
195
+ elsif part.key?("text")
196
+ flush_reasoning_contents!(output, reasoning_contents)
197
+ text_contents << Content::OutputText.new(
198
+ text: part["text"],
199
+ **(part["thoughtSignature"] ? {thought_signature: part["thoughtSignature"]} : {})
200
+ )
201
+ else
202
+ # Parts with no canonical Open Responses equivalent (inlineData,
203
+ # fileData, executableCode, etc.) are silently skipped.
204
+ next
205
+ end
206
+ end
207
+
208
+ flush_text_contents!(output, text_contents)
209
+ flush_reasoning_contents!(output, reasoning_contents)
210
+ output
211
+ end
212
+
213
+ def flush_text_contents!(output, text_contents)
214
+ return if text_contents.empty?
215
+
216
+ output << Items::Message.new(
217
+ role: "assistant",
218
+ content: text_contents.dup
219
+ )
220
+ text_contents.clear
221
+ end
222
+
223
+ def flush_reasoning_contents!(output, reasoning_contents)
224
+ return if reasoning_contents.empty?
225
+
226
+ output << Items::Reasoning.new(content: reasoning_contents.dup)
227
+ reasoning_contents.clear
228
+ end
229
+ end
230
+ end
231
+ end
232
+ end
233
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PromptBuilder
4
+ module Serializers
5
+ # Serializer for the Google Gemini API format.
6
+ # Delegates request and response handling to dedicated nested classes.
7
+ class Gemini < Base
8
+ autoload :Request, File.expand_path("gemini/request", __dir__)
9
+ autoload :Response, File.expand_path("gemini/response", __dir__)
10
+
11
+ class << self
12
+ # Export a session to Gemini 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 Gemini response into a PromptBuilder::Response.
21
+ #
22
+ # @param hash [Hash] the response hash in Gemini 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