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

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.message})",
          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.message})",
      reason: :invalid_json,
      response_body: response_json
    )
    raise err, cause: e
  end
end

.chat_structured_requested?(body) ⇒ Boolean

Returns:

  • (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

Returns:

  • (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

Returns:

  • (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