Module: SavvyOpenrouter::Patterns
- Defined in:
- lib/savvy_openrouter/patterns/structured_output.rb
Overview
Post-success validators for structured JSON (chat + Responses). Pure Ruby; use via require “savvy_openrouter/patterns”. rubocop:disable Metrics/ModuleLength – single cohesive module for optional require path
Class Method Summary collapse
- .assert_parseable_json!(text, response_json) ⇒ Object
- .chat_structured_requested?(body) ⇒ Boolean
- .deep_stringify_keys(obj) ⇒ Object
- .extract_chat_assistant_text(msg) ⇒ Object
- .extract_responses_output_text(payload) ⇒ Object
- .responses_structured_requested?(body) ⇒ Boolean
- .tool_calls_present?(msg) ⇒ Boolean
- .validate_after_success!(endpoint:, request:, response:) ⇒ Object
- .validate_chat!(request_hash, json) ⇒ Object
- .validate_responses!(request_hash, json) ⇒ Object
Class Method Details
.assert_parseable_json!(text, response_json) ⇒ Object
136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 |
# File 'lib/savvy_openrouter/patterns/structured_output.rb', line 136 def assert_parseable_json!(text, response_json) stripped = text.strip begin JSON.parse(stripped) rescue JSON::ParserError => e fenced = stripped[/```(?:json)?\s*([\s\S]*?)```/mi, 1] if fenced && !fenced.strip.empty? begin return JSON.parse(fenced.strip) rescue JSON::ParserError => e2 err = StructuredOutputError.new( "Structured output was requested but message content is not valid JSON (#{e2.})", reason: :invalid_json, response_body: response_json ) raise err, cause: e2 end end err = StructuredOutputError.new( "Structured output was requested but message content is not valid JSON (#{e.})", reason: :invalid_json, response_body: response_json ) raise err, cause: e end end |
.chat_structured_requested?(body) ⇒ Boolean
68 69 70 71 72 73 74 75 76 |
# File 'lib/savvy_openrouter/patterns/structured_output.rb', line 68 def chat_structured_requested?(body) return false unless body.is_a?(Hash) rf = body[:response_format] || body["response_format"] return false unless rf.is_a?(Hash) t = (rf[:type] || rf["type"]).to_s %w[json_schema json_object].include?(t) end |
.deep_stringify_keys(obj) ⇒ Object
164 165 166 167 168 169 170 171 172 173 174 175 |
# File 'lib/savvy_openrouter/patterns/structured_output.rb', line 164 def deep_stringify_keys(obj) case obj when Hash obj.each_with_object({}) do |(k, v), h| h[k.to_s] = deep_stringify_keys(v) end when Array obj.map { |v| deep_stringify_keys(v) } else obj end end |
.extract_chat_assistant_text(msg) ⇒ Object
91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 |
# File 'lib/savvy_openrouter/patterns/structured_output.rb', line 91 def extract_chat_assistant_text(msg) msg = deep_stringify_keys(msg) if msg.is_a?(Hash) c = msg["content"] case c when String c when Array c.filter_map do |p| next unless p.is_a?(Hash) txt = p["text"] p["type"].to_s == "text" && txt.is_a?(String) && !txt.strip.empty? ? txt : nil end.join else "" end end |
.extract_responses_output_text(payload) ⇒ Object
109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 |
# File 'lib/savvy_openrouter/patterns/structured_output.rb', line 109 def extract_responses_output_text(payload) payload = deep_stringify_keys(payload) if payload.is_a?(Hash) return "" unless payload.is_a?(Hash) ot = payload["output_text"] return ot.to_s if ot.is_a?(String) && !ot.strip.empty? outputs = payload["output"] return "" unless outputs.is_a?(Array) texts = [] outputs.each do |item| next unless item.is_a?(Hash) content = item["content"] next unless content.is_a?(Array) content.each do |part| next unless part.is_a?(Hash) next unless part["type"].to_s == "output_text" texts << part["text"].to_s end end texts.join end |
.responses_structured_requested?(body) ⇒ Boolean
78 79 80 81 82 83 84 85 86 87 88 89 |
# File 'lib/savvy_openrouter/patterns/structured_output.rb', line 78 def responses_structured_requested?(body) return false unless body.is_a?(Hash) text = body[:text] || body["text"] return false unless text.is_a?(Hash) fmt = text[:format] || text["format"] return false unless fmt.is_a?(Hash) t = (fmt[:type] || fmt["type"]).to_s %w[json_schema json_object].include?(t) end |
.tool_calls_present?(msg) ⇒ Boolean
177 178 179 180 |
# File 'lib/savvy_openrouter/patterns/structured_output.rb', line 177 def tool_calls_present?(msg) tc = msg["tool_calls"] tc.is_a?(Array) && !tc.empty? end |
.validate_after_success!(endpoint:, request:, response:) ⇒ Object
12 13 14 15 16 17 18 19 20 21 22 23 |
# File 'lib/savvy_openrouter/patterns/structured_output.rb', line 12 def validate_after_success!(endpoint:, request:, response:) return unless response.is_a?(Hash) json = deep_stringify_keys(response) case endpoint.to_s when "chat_completions" validate_chat!(request, json) when "responses" validate_responses!(request, json) end end |
.validate_chat!(request_hash, json) ⇒ Object
25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 |
# File 'lib/savvy_openrouter/patterns/structured_output.rb', line 25 def validate_chat!(request_hash, json) return unless chat_structured_requested?(request_hash) choices = json["choices"] unless choices.is_a?(Array) && !choices.empty? raise StructuredOutputError.new("No choices in chat completion", reason: :no_choices, response_body: json) end choice = choices.first msg = choice["message"] unless msg.is_a?(Hash) raise StructuredOutputError.new("Missing assistant message", reason: :no_choices, response_body: json) end return if tool_calls_present?(msg) text = extract_chat_assistant_text(msg) if text.strip.empty? raise StructuredOutputError.new( "Structured output was requested but assistant message content is empty", reason: :empty_content, response_body: json ) end assert_parseable_json!(text, json) end |
.validate_responses!(request_hash, json) ⇒ Object
53 54 55 56 57 58 59 60 61 62 63 64 65 66 |
# File 'lib/savvy_openrouter/patterns/structured_output.rb', line 53 def validate_responses!(request_hash, json) return unless responses_structured_requested?(request_hash) text = extract_responses_output_text(json) if text.strip.empty? raise StructuredOutputError.new( "Structured output was requested but Responses API returned no parseable text output", reason: :empty_content, response_body: json ) end assert_parseable_json!(text, json) end |