dspy 0.27.6 → 0.28.1

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: ccadc1d2803420cbc9389d9f4312b6b6e616e133d4e35f36454135292fd8e837
4
- data.tar.gz: e174d2cd9418a0294890e1ca6d3599681ba26c2a09c081f3b2b6652e6ce8c88d
3
+ metadata.gz: 8b377060443eeb9c3c5d975e76750d6c519d1b93cf6f20dc6ad20bcda08d1ca4
4
+ data.tar.gz: 4580845b3fd9991b531c8c2bd809595cbd3328da57a23e58307cdbe52e3822bc
5
5
  SHA512:
6
- metadata.gz: fa7eefd5f7d5555ce057f0e204b6aa5bac1188e2633c202abdf9decc75eee75ae5a232550c368dcba081fe32cc54e0d9aa8db6d565b280c260bd0b8beacff4d9
7
- data.tar.gz: 5b16c7fe7ebbe678e16235e0b0b857d3b017deb7a7eea1e6e032b4b839fbde33b4bae86fd3e50d2b6f49c114303231f60441d43d931c8f0cda3c9f5e72d786ab
6
+ metadata.gz: 444a5e08364b2e996bf49d230cb9a94a1930e26d2ea796cd0104ea4888b45ade4099cec502b92c08752631adff47b1d9d1897da9209e9faabbb28fb6761935b0
7
+ data.tar.gz: c1b3a83482c861923304c463d6f0b1c9042fbc31da920526ec18ed0f5148b4408a9023d312eded9263ca2d706e4779b78526e28dc50a17458cbbac9afa56b024
data/README.md CHANGED
@@ -59,22 +59,41 @@ puts result.sentiment # => #<Sentiment::Positive>
59
59
  puts result.confidence # => 0.85
60
60
  ```
61
61
 
62
- ### Alternative Providers
62
+ ### Access to 200+ Models Across 5 Providers
63
63
 
64
- DSPy.rb supports multiple providers with native structured outputs:
64
+ DSPy.rb provides unified access to major LLM providers with provider-specific optimizations:
65
65
 
66
66
  ```ruby
67
- # Google Gemini with native structured outputs
67
+ # OpenAI (GPT-4, GPT-4o, GPT-4o-mini, GPT-5, etc.)
68
68
  DSPy.configure do |c|
69
- c.lm = DSPy::LM.new('gemini/gemini-1.5-flash',
70
- api_key: ENV['GEMINI_API_KEY'],
71
- structured_outputs: true) # Supports gemini-1.5-pro, gemini-1.5-flash, gemini-2.0-flash-exp
69
+ c.lm = DSPy::LM.new('openai/gpt-4o-mini',
70
+ api_key: ENV['OPENAI_API_KEY'],
71
+ structured_outputs: true) # Native JSON mode
72
+ end
73
+
74
+ # Google Gemini (Gemini 1.5 Pro, Flash, Gemini 2.0, etc.)
75
+ DSPy.configure do |c|
76
+ c.lm = DSPy::LM.new('gemini/gemini-2.5-flash',
77
+ api_key: ENV['GEMINI_API_KEY'],
78
+ structured_outputs: true) # Native structured outputs
79
+ end
80
+
81
+ # Anthropic Claude (Claude 3.5, Claude 4, etc.)
82
+ DSPy.configure do |c|
83
+ c.lm = DSPy::LM.new('anthropic/claude-sonnet-4-5-20250929',
84
+ api_key: ENV['ANTHROPIC_API_KEY'],
85
+ structured_outputs: true) # Tool-based extraction (default)
86
+ end
87
+
88
+ # Ollama - Run any local model (Llama, Mistral, Gemma, etc.)
89
+ DSPy.configure do |c|
90
+ c.lm = DSPy::LM.new('ollama/llama3.2') # Free, runs locally, no API key needed
72
91
  end
73
92
 
74
- # Anthropic Claude with tool-based extraction
93
+ # OpenRouter - Access to 200+ models from multiple providers
75
94
  DSPy.configure do |c|
76
- c.lm = DSPy::LM.new('anthropic/claude-3-sonnet-20241022',
77
- api_key: ENV['ANTHROPIC_API_KEY']) # Automatic strategy selection
95
+ c.lm = DSPy::LM.new('openrouter/deepseek/deepseek-chat-v3.1:free',
96
+ api_key: ENV['OPENROUTER_API_KEY'])
78
97
  end
79
98
  ```
80
99
 
@@ -13,7 +13,7 @@ module DSPy
13
13
  'openrouter' => 'OpenrouterAdapter'
14
14
  }.freeze
15
15
 
16
- PROVIDERS_WITH_EXTRA_OPTIONS = %w[openai ollama gemini openrouter].freeze
16
+ PROVIDERS_WITH_EXTRA_OPTIONS = %w[openai anthropic ollama gemini openrouter].freeze
17
17
 
18
18
  class << self
19
19
  # Creates an adapter instance based on model_id
@@ -6,10 +6,11 @@ require_relative '../vision_models'
6
6
  module DSPy
7
7
  class LM
8
8
  class AnthropicAdapter < Adapter
9
- def initialize(model:, api_key:)
10
- super
9
+ def initialize(model:, api_key:, structured_outputs: true)
10
+ super(model: model, api_key: api_key)
11
11
  validate_api_key!(api_key, 'anthropic')
12
12
  @client = Anthropic::Client.new(api_key: api_key)
13
+ @structured_outputs_enabled = structured_outputs
13
14
  end
14
15
 
15
16
  def chat(messages:, signature: nil, **extra_params, &block)
@@ -11,29 +11,32 @@ module DSPy
11
11
  extend T::Sig
12
12
 
13
13
  # Models that support structured outputs (JSON + Schema)
14
- # Based on official Google documentation (Sept 2025)
14
+ # Based on official Google documentation: https://ai.google.dev/gemini-api/docs/models/gemini
15
+ # Last updated: Oct 2025
16
+ # Note: Gemini 1.5 series deprecated Oct 2025
15
17
  STRUCTURED_OUTPUT_MODELS = T.let([
16
- # Gemini 1.5 series
17
- "gemini-1.5-pro",
18
- "gemini-1.5-pro-preview-0514",
19
- "gemini-1.5-pro-preview-0409",
20
- "gemini-1.5-flash", # ✅ Now supports structured outputs
21
- "gemini-1.5-flash-8b",
22
18
  # Gemini 2.0 series
23
19
  "gemini-2.0-flash",
24
- "gemini-2.0-flash-001",
25
- # Gemini 2.5 series
20
+ "gemini-2.0-flash-lite",
21
+ # Gemini 2.5 series (current)
26
22
  "gemini-2.5-pro",
27
- "gemini-2.5-flash",
28
- "gemini-2.5-flash-lite"
23
+ "gemini-2.5-flash",
24
+ "gemini-2.5-flash-lite",
25
+ "gemini-2.5-flash-image"
29
26
  ].freeze, T::Array[String])
30
27
 
31
- # Models that do not support structured outputs (legacy only)
28
+ # Models that do not support structured outputs or are deprecated
32
29
  UNSUPPORTED_MODELS = T.let([
33
- # Legacy Gemini 1.0 series only
34
- "gemini-pro",
30
+ # Legacy Gemini 1.0 series
31
+ "gemini-pro",
35
32
  "gemini-1.0-pro-002",
36
- "gemini-1.0-pro"
33
+ "gemini-1.0-pro",
34
+ # Deprecated Gemini 1.5 series (removed Oct 2025)
35
+ "gemini-1.5-pro",
36
+ "gemini-1.5-pro-preview-0514",
37
+ "gemini-1.5-pro-preview-0409",
38
+ "gemini-1.5-flash",
39
+ "gemini-1.5-flash-8b"
37
40
  ].freeze, T::Array[String])
38
41
 
39
42
  sig { params(signature_class: T.class_of(DSPy::Signature)).returns(T::Hash[Symbol, T.untyped]) }
@@ -111,7 +114,13 @@ module DSPy
111
114
  case property_schema[:type]
112
115
  when "string"
113
116
  result = { type: "string" }
114
- result[:enum] = property_schema[:enum] if property_schema[:enum]
117
+ # Gemini responseJsonSchema doesn't support const, so convert to single-value enum
118
+ # See: https://ai.google.dev/api/generate-content#FIELDS.response_json_schema
119
+ if property_schema[:const]
120
+ result[:enum] = [property_schema[:const]]
121
+ elsif property_schema[:enum]
122
+ result[:enum] = property_schema[:enum]
123
+ end
115
124
  result
116
125
  when "integer"
117
126
  { type: "integer" }
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "sorbet-runtime"
4
+
5
+ module DSPy
6
+ class LM
7
+ # Simple chat strategy that passes messages through without JSON extraction
8
+ class ChatStrategy
9
+ extend T::Sig
10
+
11
+ sig { params(adapter: T.untyped).void }
12
+ def initialize(adapter)
13
+ @adapter = adapter
14
+ end
15
+
16
+ # No modifications to messages for simple chat
17
+ sig { params(messages: T::Array[T::Hash[Symbol, T.untyped]], request_params: T::Hash[Symbol, T.untyped]).void }
18
+ def prepare_request(messages, request_params)
19
+ # Pass through unchanged
20
+ end
21
+
22
+ # No JSON extraction for chat
23
+ sig { params(response: DSPy::LM::Response).returns(NilClass) }
24
+ def extract_json(response)
25
+ nil
26
+ end
27
+
28
+ sig { returns(String) }
29
+ def name
30
+ 'chat'
31
+ end
32
+
33
+ private
34
+
35
+ attr_reader :adapter
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,217 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "sorbet-runtime"
4
+ require_relative "adapters/openai/schema_converter"
5
+ require_relative "adapters/gemini/schema_converter"
6
+
7
+ module DSPy
8
+ class LM
9
+ # JSON extraction strategy with provider-specific handling
10
+ class JSONStrategy
11
+ extend T::Sig
12
+
13
+ sig { params(adapter: T.untyped, signature_class: T.class_of(DSPy::Signature)).void }
14
+ def initialize(adapter, signature_class)
15
+ @adapter = adapter
16
+ @signature_class = signature_class
17
+ end
18
+
19
+ # Prepare request with provider-specific JSON extraction parameters
20
+ sig { params(messages: T::Array[T::Hash[Symbol, T.untyped]], request_params: T::Hash[Symbol, T.untyped]).void }
21
+ def prepare_request(messages, request_params)
22
+ adapter_class_name = adapter.class.name
23
+
24
+ if adapter_class_name.include?('OpenAIAdapter') || adapter_class_name.include?('OllamaAdapter')
25
+ prepare_openai_request(request_params)
26
+ elsif adapter_class_name.include?('AnthropicAdapter')
27
+ prepare_anthropic_request(messages, request_params)
28
+ elsif adapter_class_name.include?('GeminiAdapter')
29
+ prepare_gemini_request(request_params)
30
+ end
31
+ # Unknown provider - no special handling
32
+ end
33
+
34
+ # Extract JSON from response based on provider
35
+ sig { params(response: DSPy::LM::Response).returns(T.nilable(String)) }
36
+ def extract_json(response)
37
+ adapter_class_name = adapter.class.name
38
+
39
+ if adapter_class_name.include?('OpenAIAdapter') || adapter_class_name.include?('OllamaAdapter')
40
+ # OpenAI/Ollama: try to extract JSON from various formats
41
+ extract_json_from_content(response.content)
42
+ elsif adapter_class_name.include?('AnthropicAdapter')
43
+ # Anthropic: try tool use first if structured_outputs enabled, else use content extraction
44
+ structured_outputs_enabled = adapter.instance_variable_get(:@structured_outputs_enabled)
45
+ structured_outputs_enabled = true if structured_outputs_enabled.nil? # Default to true
46
+
47
+ if structured_outputs_enabled
48
+ extracted = extract_anthropic_tool_json(response)
49
+ extracted || extract_json_from_content(response.content)
50
+ else
51
+ # Skip tool extraction, use enhanced prompting extraction
52
+ extract_json_from_content(response.content)
53
+ end
54
+ elsif adapter_class_name.include?('GeminiAdapter')
55
+ # Gemini: try to extract JSON from various formats
56
+ extract_json_from_content(response.content)
57
+ else
58
+ # Unknown provider: try to extract JSON
59
+ extract_json_from_content(response.content)
60
+ end
61
+ end
62
+
63
+ sig { returns(String) }
64
+ def name
65
+ 'json'
66
+ end
67
+
68
+ private
69
+
70
+ attr_reader :adapter, :signature_class
71
+
72
+ # OpenAI/Ollama preparation
73
+ sig { params(request_params: T::Hash[Symbol, T.untyped]).void }
74
+ def prepare_openai_request(request_params)
75
+ # Check if structured outputs are supported
76
+ if adapter.instance_variable_get(:@structured_outputs_enabled) &&
77
+ DSPy::LM::Adapters::OpenAI::SchemaConverter.supports_structured_outputs?(adapter.model)
78
+ response_format = DSPy::LM::Adapters::OpenAI::SchemaConverter.to_openai_format(signature_class)
79
+ request_params[:response_format] = response_format
80
+ end
81
+ end
82
+
83
+ # Anthropic preparation
84
+ sig { params(messages: T::Array[T::Hash[Symbol, T.untyped]], request_params: T::Hash[Symbol, T.untyped]).void }
85
+ def prepare_anthropic_request(messages, request_params)
86
+ # Only use tool-based extraction if structured_outputs is enabled (default: true)
87
+ structured_outputs_enabled = adapter.instance_variable_get(:@structured_outputs_enabled)
88
+
89
+ # Default to true if not set (backward compatibility)
90
+ structured_outputs_enabled = true if structured_outputs_enabled.nil?
91
+
92
+ return unless structured_outputs_enabled
93
+
94
+ # Convert signature to tool schema
95
+ tool_schema = convert_to_anthropic_tool_schema
96
+
97
+ # Add tool definition
98
+ request_params[:tools] = [tool_schema]
99
+
100
+ # Force tool use
101
+ request_params[:tool_choice] = {
102
+ type: "tool",
103
+ name: "json_output"
104
+ }
105
+ end
106
+
107
+ # Gemini preparation
108
+ sig { params(request_params: T::Hash[Symbol, T.untyped]).void }
109
+ def prepare_gemini_request(request_params)
110
+ # Check if structured outputs are supported
111
+ if adapter.instance_variable_get(:@structured_outputs_enabled) &&
112
+ DSPy::LM::Adapters::Gemini::SchemaConverter.supports_structured_outputs?(adapter.model)
113
+ schema = DSPy::LM::Adapters::Gemini::SchemaConverter.to_gemini_format(signature_class)
114
+
115
+ request_params[:generation_config] = {
116
+ response_mime_type: "application/json",
117
+ response_json_schema: schema
118
+ }
119
+ end
120
+ end
121
+
122
+ # Convert signature to Anthropic tool schema
123
+ sig { returns(T::Hash[Symbol, T.untyped]) }
124
+ def convert_to_anthropic_tool_schema
125
+ output_fields = signature_class.output_field_descriptors
126
+
127
+ {
128
+ name: "json_output",
129
+ description: "Output the result in the required JSON format",
130
+ input_schema: {
131
+ type: "object",
132
+ properties: build_properties_from_fields(output_fields),
133
+ required: output_fields.keys.map(&:to_s)
134
+ }
135
+ }
136
+ end
137
+
138
+ # Build JSON schema properties from output fields
139
+ sig { params(fields: T::Hash[Symbol, T.untyped]).returns(T::Hash[String, T.untyped]) }
140
+ def build_properties_from_fields(fields)
141
+ properties = {}
142
+ fields.each do |field_name, descriptor|
143
+ properties[field_name.to_s] = DSPy::TypeSystem::SorbetJsonSchema.type_to_json_schema(descriptor.type)
144
+ end
145
+ properties
146
+ end
147
+
148
+ # Extract JSON from Anthropic tool use response
149
+ sig { params(response: DSPy::LM::Response).returns(T.nilable(String)) }
150
+ def extract_anthropic_tool_json(response)
151
+ # Check for tool calls in metadata
152
+ if response.metadata.respond_to?(:tool_calls) && response.metadata.tool_calls
153
+ tool_calls = response.metadata.tool_calls
154
+ if tool_calls.is_a?(Array) && !tool_calls.empty?
155
+ first_call = tool_calls.first
156
+ if first_call[:name] == "json_output" && first_call[:input]
157
+ return JSON.generate(first_call[:input])
158
+ end
159
+ end
160
+ end
161
+
162
+ nil
163
+ end
164
+
165
+ # Extract JSON from content that may contain markdown or plain JSON
166
+ sig { params(content: String).returns(String) }
167
+ def extract_json_from_content(content)
168
+ return content if content.nil? || content.empty?
169
+
170
+ # Try 1: Check for ```json code block (with or without preceding text)
171
+ if content.include?('```json')
172
+ json_match = content.match(/```json\s*\n(.*?)\n```/m)
173
+ return json_match[1].strip if json_match
174
+ end
175
+
176
+ # Try 2: Check for generic ``` code block
177
+ if content.include?('```')
178
+ code_match = content.match(/```\s*\n(.*?)\n```/m)
179
+ if code_match
180
+ potential_json = code_match[1].strip
181
+ # Verify it's JSON
182
+ begin
183
+ JSON.parse(potential_json)
184
+ return potential_json
185
+ rescue JSON::ParserError
186
+ # Not valid JSON, continue
187
+ end
188
+ end
189
+ end
190
+
191
+ # Try 3: Try parsing entire content as JSON
192
+ begin
193
+ JSON.parse(content)
194
+ return content
195
+ rescue JSON::ParserError
196
+ # Not pure JSON, try extracting
197
+ end
198
+
199
+ # Try 4: Look for JSON object pattern in text (greedy match for nested objects)
200
+ json_pattern = /\{(?:[^{}]|\{(?:[^{}]|\{[^{}]*\})*\})*\}/m
201
+ json_match = content.match(json_pattern)
202
+ if json_match
203
+ potential_json = json_match[0]
204
+ begin
205
+ JSON.parse(potential_json)
206
+ return potential_json
207
+ rescue JSON::ParserError
208
+ # Not valid JSON
209
+ end
210
+ end
211
+
212
+ # Return content as-is if no JSON found
213
+ content
214
+ end
215
+ end
216
+ end
217
+ end
data/lib/dspy/lm.rb CHANGED
@@ -20,12 +20,13 @@ require_relative 'lm/adapters/gemini_adapter'
20
20
  require_relative 'lm/adapters/openrouter_adapter'
21
21
 
22
22
  # Load strategy system
23
- require_relative 'lm/strategy_selector'
24
- require_relative 'lm/retry_handler'
23
+ require_relative 'lm/chat_strategy'
24
+ require_relative 'lm/json_strategy'
25
25
 
26
26
  # Load message builder and message types
27
27
  require_relative 'lm/message'
28
28
  require_relative 'lm/message_builder'
29
+ require_relative 'structured_outputs_prompt'
29
30
 
30
31
  module DSPy
31
32
  class LM
@@ -64,7 +65,10 @@ module DSPy
64
65
  response = instrument_lm_request(messages, signature_class.name) do
65
66
  chat_with_strategy(messages, signature_class, &block)
66
67
  end
67
-
68
+
69
+ # Emit the standard lm.tokens event (consistent with raw_chat)
70
+ emit_token_usage(response, signature_class.name)
71
+
68
72
  # Parse response (no longer needs separate instrumentation)
69
73
  parsed_result = parse_response(response, input_values, signature_class)
70
74
 
@@ -96,21 +100,15 @@ module DSPy
96
100
  private
97
101
 
98
102
  def chat_with_strategy(messages, signature_class, &block)
99
- # Select the best strategy for JSON extraction
100
- strategy_selector = StrategySelector.new(adapter, signature_class)
101
- initial_strategy = strategy_selector.select
102
-
103
- if DSPy.config.structured_outputs.retry_enabled && signature_class
104
- # Use retry handler for JSON responses
105
- retry_handler = RetryHandler.new(adapter, signature_class)
106
-
107
- retry_handler.with_retry(initial_strategy) do |strategy|
108
- execute_chat_with_strategy(messages, signature_class, strategy, &block)
109
- end
103
+ # Choose strategy based on whether we need JSON extraction
104
+ strategy = if signature_class
105
+ JSONStrategy.new(adapter, signature_class)
110
106
  else
111
- # No retry logic, just execute once
112
- execute_chat_with_strategy(messages, signature_class, initial_strategy, &block)
107
+ ChatStrategy.new(adapter)
113
108
  end
109
+
110
+ # Execute with the selected strategy (no retry, no fallback)
111
+ execute_chat_with_strategy(messages, signature_class, strategy, &block)
114
112
  end
115
113
 
116
114
  def execute_chat_with_strategy(messages, signature_class, strategy, &block)
@@ -179,26 +177,53 @@ module DSPy
179
177
 
180
178
  def build_messages(inference_module, input_values)
181
179
  messages = []
182
-
180
+
181
+ # Determine if structured outputs will be used and wrap prompt if so
182
+ base_prompt = inference_module.prompt
183
+ prompt = if will_use_structured_outputs?(inference_module.signature_class)
184
+ StructuredOutputsPrompt.new(**base_prompt.to_h)
185
+ else
186
+ base_prompt
187
+ end
188
+
183
189
  # Add system message
184
- system_prompt = inference_module.system_signature
190
+ system_prompt = prompt.render_system_prompt
185
191
  if system_prompt
186
192
  messages << Message.new(
187
193
  role: Message::Role::System,
188
194
  content: system_prompt
189
195
  )
190
196
  end
191
-
197
+
192
198
  # Add user message
193
- user_prompt = inference_module.user_signature(input_values)
199
+ user_prompt = prompt.render_user_prompt(input_values)
194
200
  messages << Message.new(
195
201
  role: Message::Role::User,
196
202
  content: user_prompt
197
203
  )
198
-
204
+
199
205
  messages
200
206
  end
201
207
 
208
+ def will_use_structured_outputs?(signature_class)
209
+ return false unless signature_class
210
+
211
+ adapter_class_name = adapter.class.name
212
+
213
+ if adapter_class_name.include?('OpenAIAdapter') || adapter_class_name.include?('OllamaAdapter')
214
+ adapter.instance_variable_get(:@structured_outputs_enabled) &&
215
+ DSPy::LM::Adapters::OpenAI::SchemaConverter.supports_structured_outputs?(adapter.model)
216
+ elsif adapter_class_name.include?('GeminiAdapter')
217
+ adapter.instance_variable_get(:@structured_outputs_enabled) &&
218
+ DSPy::LM::Adapters::Gemini::SchemaConverter.supports_structured_outputs?(adapter.model)
219
+ elsif adapter_class_name.include?('AnthropicAdapter')
220
+ structured_outputs_enabled = adapter.instance_variable_get(:@structured_outputs_enabled)
221
+ structured_outputs_enabled.nil? ? true : structured_outputs_enabled
222
+ else
223
+ false
224
+ end
225
+ end
226
+
202
227
  def parse_response(response, input_values, signature_class)
203
228
  # Try to parse the response as JSON
204
229
  content = response.content
@@ -208,26 +208,26 @@ module DSPy
208
208
  sig { params(value: T.untyped, union_type: T.untyped).returns(T.untyped) }
209
209
  def coerce_union_value(value, union_type)
210
210
  return value unless value.is_a?(Hash)
211
-
211
+
212
212
  # Check for _type discriminator field
213
213
  type_name = value[:_type] || value["_type"]
214
214
  return value unless type_name
215
-
215
+
216
216
  # Find matching struct type in the union
217
217
  union_type.types.each do |type|
218
218
  next if type == T::Utils.coerce(NilClass)
219
-
219
+
220
220
  if type.is_a?(T::Types::Simple) && type.raw_type < T::Struct
221
221
  struct_name = type.raw_type.name.split('::').last
222
222
  if struct_name == type_name
223
223
  # Convert string keys to symbols and remove _type
224
224
  symbolized_hash = value.transform_keys(&:to_sym)
225
225
  symbolized_hash.delete(:_type)
226
-
226
+
227
227
  # Coerce struct field values based on their types
228
228
  struct_class = type.raw_type
229
229
  struct_props = struct_class.props
230
-
230
+
231
231
  # ONLY include fields that exist in the struct
232
232
  coerced_hash = {}
233
233
  struct_props.each_key do |key|
@@ -236,13 +236,13 @@ module DSPy
236
236
  coerced_hash[key] = coerce_value_to_type(symbolized_hash[key], prop_type)
237
237
  end
238
238
  end
239
-
239
+
240
240
  # Create the struct instance with coerced values
241
241
  return struct_class.new(**coerced_hash)
242
242
  end
243
243
  end
244
244
  end
245
-
245
+
246
246
  # If no matching type found, return original value
247
247
  value
248
248
  rescue ArgumentError => e