dspy 0.30.1 → 0.31.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.
@@ -1,73 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require 'openai'
4
-
5
- module DSPy
6
- class LM
7
- class OllamaAdapter < OpenAIAdapter
8
- DEFAULT_BASE_URL = 'http://localhost:11434/v1'
9
-
10
- def initialize(model:, api_key: nil, base_url: nil, structured_outputs: true)
11
- # Ollama doesn't require API key for local instances
12
- # But may need it for remote/protected instances
13
- api_key ||= 'ollama' # OpenAI client requires non-empty key
14
- base_url ||= DEFAULT_BASE_URL
15
-
16
- # Store base_url before calling super
17
- @base_url = base_url
18
-
19
- # Don't call parent's initialize, do it manually to control client creation
20
- @model = model
21
- @api_key = api_key
22
- @structured_outputs_enabled = structured_outputs
23
- validate_configuration!
24
-
25
- # Create client with custom base URL
26
- @client = OpenAI::Client.new(
27
- api_key: @api_key,
28
- base_url: @base_url
29
- )
30
- end
31
-
32
- def chat(messages:, signature: nil, response_format: nil, &block)
33
- # For Ollama, we need to be more lenient with structured outputs
34
- # as it may not fully support OpenAI's response_format spec
35
- begin
36
- super
37
- rescue => e
38
- # If structured output fails, retry with enhanced prompting
39
- if @structured_outputs_enabled && signature && e.message.include?('response_format')
40
- DSPy.logger.debug("Ollama structured output failed, falling back to enhanced prompting")
41
- @structured_outputs_enabled = false
42
- retry
43
- else
44
- raise
45
- end
46
- end
47
- end
48
-
49
- private
50
-
51
- def validate_configuration!
52
- super
53
- # Additional Ollama-specific validation could go here
54
- end
55
-
56
- def validate_api_key!(api_key, provider)
57
- # For Ollama, API key is optional for local instances
58
- # Only validate if it looks like a remote URL
59
- if @base_url && !@base_url.include?('localhost') && !@base_url.include?('127.0.0.1')
60
- super
61
- end
62
- end
63
-
64
-
65
- # Ollama may have different model support for structured outputs
66
- def supports_structured_outputs?
67
- # For now, assume all Ollama models support basic JSON mode
68
- # but may not support full OpenAI structured output spec
69
- true
70
- end
71
- end
72
- end
73
- end
@@ -1,359 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "sorbet-runtime"
4
-
5
- module DSPy
6
- class LM
7
- module Adapters
8
- module OpenAI
9
- # Converts DSPy signatures to OpenAI structured output format
10
- class SchemaConverter
11
- extend T::Sig
12
-
13
- # Models that support structured outputs as of July 2025
14
- STRUCTURED_OUTPUT_MODELS = T.let([
15
- "gpt-4o-mini",
16
- "gpt-4o-2024-08-06",
17
- "gpt-4o",
18
- "gpt-4-turbo",
19
- "gpt-4-turbo-2024-04-09"
20
- ].freeze, T::Array[String])
21
-
22
- sig { params(signature_class: T.class_of(DSPy::Signature), name: T.nilable(String), strict: T::Boolean).returns(T::Hash[Symbol, T.untyped]) }
23
- def self.to_openai_format(signature_class, name: nil, strict: true)
24
- # Get the output JSON schema from the signature class
25
- output_schema = signature_class.output_json_schema
26
-
27
- # Convert oneOf to anyOf where safe, or raise error for unsupported cases
28
- output_schema = convert_oneof_to_anyof_if_safe(output_schema)
29
-
30
- # Build the complete schema with OpenAI-specific modifications
31
- dspy_schema = {
32
- "$schema": "http://json-schema.org/draft-06/schema#",
33
- type: "object",
34
- properties: output_schema[:properties] || {},
35
- required: openai_required_fields(signature_class, output_schema)
36
- }
37
-
38
- # Generate a schema name if not provided
39
- schema_name = name || generate_schema_name(signature_class)
40
-
41
- # Remove the $schema field as OpenAI doesn't use it
42
- openai_schema = dspy_schema.except(:$schema)
43
-
44
- # Add additionalProperties: false for strict mode and fix nested struct schemas
45
- if strict
46
- openai_schema = add_additional_properties_recursively(openai_schema)
47
- openai_schema = fix_nested_struct_required_fields(openai_schema)
48
- end
49
-
50
- # Wrap in OpenAI's required format
51
- {
52
- type: "json_schema",
53
- json_schema: {
54
- name: schema_name,
55
- strict: strict,
56
- schema: openai_schema
57
- }
58
- }
59
- end
60
-
61
- # Convert oneOf to anyOf if safe (discriminated unions), otherwise raise error
62
- sig { params(schema: T.untyped).returns(T.untyped) }
63
- def self.convert_oneof_to_anyof_if_safe(schema)
64
- return schema unless schema.is_a?(Hash)
65
-
66
- result = schema.dup
67
-
68
- # Check if this schema has oneOf that we can safely convert
69
- if result[:oneOf]
70
- if all_have_discriminators?(result[:oneOf])
71
- # Safe to convert - discriminators ensure mutual exclusivity
72
- result[:anyOf] = result.delete(:oneOf).map { |s| convert_oneof_to_anyof_if_safe(s) }
73
- else
74
- # Unsafe conversion - raise error
75
- raise DSPy::UnsupportedSchemaError.new(
76
- "OpenAI structured outputs do not support oneOf schemas without discriminator fields. " \
77
- "The schema contains union types that cannot be safely converted to anyOf. " \
78
- "Please use enhanced_prompting strategy instead or add discriminator fields to union types."
79
- )
80
- end
81
- end
82
-
83
- # Recursively process nested schemas
84
- if result[:properties].is_a?(Hash)
85
- result[:properties] = result[:properties].transform_values { |v| convert_oneof_to_anyof_if_safe(v) }
86
- end
87
-
88
- if result[:items].is_a?(Hash)
89
- result[:items] = convert_oneof_to_anyof_if_safe(result[:items])
90
- end
91
-
92
- # Process arrays of schema items
93
- if result[:items].is_a?(Array)
94
- result[:items] = result[:items].map { |item|
95
- item.is_a?(Hash) ? convert_oneof_to_anyof_if_safe(item) : item
96
- }
97
- end
98
-
99
- # Process anyOf arrays (in case there are nested oneOf within anyOf)
100
- if result[:anyOf].is_a?(Array)
101
- result[:anyOf] = result[:anyOf].map { |item|
102
- item.is_a?(Hash) ? convert_oneof_to_anyof_if_safe(item) : item
103
- }
104
- end
105
-
106
- result
107
- end
108
-
109
- # Check if all schemas in a oneOf array have discriminator fields (const properties)
110
- sig { params(schemas: T::Array[T.untyped]).returns(T::Boolean) }
111
- def self.all_have_discriminators?(schemas)
112
- schemas.all? do |schema|
113
- next false unless schema.is_a?(Hash)
114
- next false unless schema[:properties].is_a?(Hash)
115
-
116
- # Check if any property has a const value (our discriminator pattern)
117
- schema[:properties].any? { |_, prop| prop.is_a?(Hash) && prop[:const] }
118
- end
119
- end
120
-
121
- sig { params(model: String).returns(T::Boolean) }
122
- def self.supports_structured_outputs?(model)
123
- # Extract base model name without provider prefix
124
- base_model = model.sub(/^openai\//, "")
125
-
126
- # Check if it's a supported model or a newer version
127
- STRUCTURED_OUTPUT_MODELS.any? { |supported| base_model.start_with?(supported) }
128
- end
129
-
130
- sig { params(schema: T::Hash[Symbol, T.untyped]).returns(T::Array[String]) }
131
- def self.validate_compatibility(schema)
132
- issues = []
133
-
134
- # Check for deeply nested objects (OpenAI has depth limits)
135
- depth = calculate_depth(schema)
136
- if depth > 5
137
- issues << "Schema depth (#{depth}) exceeds recommended limit of 5 levels"
138
- end
139
-
140
- # Check for unsupported JSON Schema features
141
- if contains_pattern_properties?(schema)
142
- issues << "Pattern properties are not supported in OpenAI structured outputs"
143
- end
144
-
145
- if contains_conditional_schemas?(schema)
146
- issues << "Conditional schemas (if/then/else) are not supported"
147
- end
148
-
149
- issues
150
- end
151
-
152
- private
153
-
154
- # OpenAI structured outputs requires ALL properties to be in the required array
155
- # For T.nilable fields without defaults, we warn the user and mark as required
156
- sig { params(signature_class: T.class_of(DSPy::Signature), output_schema: T::Hash[Symbol, T.untyped]).returns(T::Array[String]) }
157
- def self.openai_required_fields(signature_class, output_schema)
158
- all_properties = output_schema[:properties]&.keys || []
159
- original_required = output_schema[:required] || []
160
-
161
- # For OpenAI structured outputs, we need ALL properties to be required
162
- # but warn about T.nilable fields without defaults
163
- field_descriptors = signature_class.instance_variable_get(:@output_field_descriptors) || {}
164
-
165
- all_properties.each do |property_name|
166
- descriptor = field_descriptors[property_name.to_sym]
167
-
168
- # If field is not originally required and doesn't have a default
169
- if !original_required.include?(property_name.to_s) && descriptor && !descriptor.has_default
170
- DSPy.logger.warn(
171
- "OpenAI structured outputs: T.nilable field '#{property_name}' without default will be marked as required. " \
172
- "Consider adding a default value or using a different provider for optional fields."
173
- )
174
- end
175
- end
176
-
177
- # Return all properties as required (OpenAI requirement)
178
- all_properties.map(&:to_s)
179
- end
180
-
181
- # Fix nested struct schemas to include all properties in required array (OpenAI requirement)
182
- sig { params(schema: T::Hash[Symbol, T.untyped]).returns(T::Hash[Symbol, T.untyped]) }
183
- def self.fix_nested_struct_required_fields(schema)
184
- return schema unless schema.is_a?(Hash)
185
-
186
- result = schema.dup
187
-
188
- # If this is an object with properties, make all properties required
189
- if result[:type] == "object" && result[:properties].is_a?(Hash)
190
- all_property_names = result[:properties].keys.map(&:to_s)
191
- result[:required] = all_property_names unless result[:required] == all_property_names
192
- end
193
-
194
- # Process nested objects recursively
195
- if result[:properties].is_a?(Hash)
196
- result[:properties] = result[:properties].transform_values do |prop|
197
- if prop.is_a?(Hash)
198
- processed = fix_nested_struct_required_fields(prop)
199
- # Handle arrays with object items
200
- if processed[:type] == "array" && processed[:items].is_a?(Hash)
201
- processed[:items] = fix_nested_struct_required_fields(processed[:items])
202
- end
203
- processed
204
- else
205
- prop
206
- end
207
- end
208
- end
209
-
210
- result
211
- end
212
-
213
- sig { params(schema: T::Hash[Symbol, T.untyped]).returns(T::Hash[Symbol, T.untyped]) }
214
- def self.add_additional_properties_recursively(schema)
215
- return schema unless schema.is_a?(Hash)
216
-
217
- result = schema.dup
218
-
219
- # Add additionalProperties: false if this is an object
220
- if result[:type] == "object"
221
- result[:additionalProperties] = false
222
- end
223
-
224
- # Process properties recursively
225
- if result[:properties].is_a?(Hash)
226
- result[:properties] = result[:properties].transform_values do |prop|
227
- if prop.is_a?(Hash)
228
- processed = add_additional_properties_recursively(prop)
229
- # Special handling for arrays - ensure their items have additionalProperties if they're objects
230
- if processed[:type] == "array" && processed[:items].is_a?(Hash)
231
- processed[:items] = add_additional_properties_recursively(processed[:items])
232
- end
233
- processed
234
- else
235
- prop
236
- end
237
- end
238
- end
239
-
240
- # Process array items
241
- if result[:items].is_a?(Hash)
242
- processed_items = add_additional_properties_recursively(result[:items])
243
- # OpenAI requires additionalProperties on all objects, even in array items
244
- if processed_items.is_a?(Hash) && processed_items[:type] == "object" && !processed_items.key?(:additionalProperties)
245
- processed_items[:additionalProperties] = false
246
- end
247
- result[:items] = processed_items
248
- elsif result[:items].is_a?(Array)
249
- # Handle tuple validation
250
- result[:items] = result[:items].map do |item|
251
- processed = item.is_a?(Hash) ? add_additional_properties_recursively(item) : item
252
- if processed.is_a?(Hash) && processed[:type] == "object" && !processed.key?(:additionalProperties)
253
- processed[:additionalProperties] = false
254
- end
255
- processed
256
- end
257
- end
258
-
259
- # Process anyOf/allOf (oneOf should be converted to anyOf by this point)
260
- [:anyOf, :allOf].each do |key|
261
- if result[key].is_a?(Array)
262
- result[key] = result[key].map do |sub_schema|
263
- sub_schema.is_a?(Hash) ? add_additional_properties_recursively(sub_schema) : sub_schema
264
- end
265
- end
266
- end
267
-
268
- result
269
- end
270
-
271
- sig { params(signature_class: T.class_of(DSPy::Signature)).returns(String) }
272
- def self.generate_schema_name(signature_class)
273
- # Use the signature class name
274
- class_name = signature_class.name&.split("::")&.last
275
- if class_name
276
- class_name.gsub(/[^a-zA-Z0-9_]/, "_").downcase
277
- else
278
- # Fallback to a generic name
279
- "dspy_output_#{Time.now.to_i}"
280
- end
281
- end
282
-
283
- sig { params(schema: T::Hash[Symbol, T.untyped], current_depth: Integer).returns(Integer) }
284
- def self.calculate_depth(schema, current_depth = 0)
285
- return current_depth unless schema.is_a?(Hash)
286
-
287
- max_depth = current_depth
288
-
289
- # Check properties
290
- if schema[:properties].is_a?(Hash)
291
- schema[:properties].each_value do |prop|
292
- if prop.is_a?(Hash)
293
- prop_depth = calculate_depth(prop, current_depth + 1)
294
- max_depth = [max_depth, prop_depth].max
295
- end
296
- end
297
- end
298
-
299
- # Check array items
300
- if schema[:items].is_a?(Hash)
301
- items_depth = calculate_depth(schema[:items], current_depth + 1)
302
- max_depth = [max_depth, items_depth].max
303
- end
304
-
305
- # Check anyOf/allOf (oneOf should be converted to anyOf by this point)
306
- [:anyOf, :allOf].each do |key|
307
- if schema[key].is_a?(Array)
308
- schema[key].each do |sub_schema|
309
- if sub_schema.is_a?(Hash)
310
- sub_depth = calculate_depth(sub_schema, current_depth + 1)
311
- max_depth = [max_depth, sub_depth].max
312
- end
313
- end
314
- end
315
- end
316
-
317
- max_depth
318
- end
319
-
320
- sig { params(schema: T::Hash[Symbol, T.untyped]).returns(T::Boolean) }
321
- def self.contains_pattern_properties?(schema)
322
- return true if schema[:patternProperties]
323
-
324
- # Recursively check nested schemas (oneOf should be converted to anyOf by this point)
325
- [:properties, :items, :anyOf, :allOf].each do |key|
326
- value = schema[key]
327
- case value
328
- when Hash
329
- return true if contains_pattern_properties?(value)
330
- when Array
331
- return true if value.any? { |v| v.is_a?(Hash) && contains_pattern_properties?(v) }
332
- end
333
- end
334
-
335
- false
336
- end
337
-
338
- sig { params(schema: T::Hash[Symbol, T.untyped]).returns(T::Boolean) }
339
- def self.contains_conditional_schemas?(schema)
340
- return true if schema[:if] || schema[:then] || schema[:else]
341
-
342
- # Recursively check nested schemas (oneOf should be converted to anyOf by this point)
343
- [:properties, :items, :anyOf, :allOf].each do |key|
344
- value = schema[key]
345
- case value
346
- when Hash
347
- return true if contains_conditional_schemas?(value)
348
- when Array
349
- return true if value.any? { |v| v.is_a?(Hash) && contains_conditional_schemas?(v) }
350
- end
351
- end
352
-
353
- false
354
- end
355
- end
356
- end
357
- end
358
- end
359
- end
@@ -1,188 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require 'openai'
4
- require_relative 'openai/schema_converter'
5
- require_relative '../vision_models'
6
-
7
- module DSPy
8
- class LM
9
- class OpenAIAdapter < Adapter
10
- def initialize(model:, api_key:, structured_outputs: false)
11
- super(model: model, api_key: api_key)
12
- validate_api_key!(api_key, 'openai')
13
- @client = OpenAI::Client.new(api_key: api_key)
14
- @structured_outputs_enabled = structured_outputs
15
- end
16
-
17
- def chat(messages:, signature: nil, response_format: nil, &block)
18
- normalized_messages = normalize_messages(messages)
19
-
20
- # Validate vision support if images are present
21
- if contains_images?(normalized_messages)
22
- VisionModels.validate_vision_support!('openai', model)
23
- # Convert messages to OpenAI format with proper image handling
24
- normalized_messages = format_multimodal_messages(normalized_messages)
25
- end
26
-
27
- # Handle O1 model restrictions - convert system messages to user messages
28
- if o1_model?(model)
29
- normalized_messages = handle_o1_messages(normalized_messages)
30
- end
31
-
32
- request_params = default_request_params.merge(
33
- messages: normalized_messages
34
- )
35
-
36
- # Add temperature based on model capabilities
37
- unless o1_model?(model)
38
- temperature = case model
39
- when /^gpt-5/, /^gpt-4o/
40
- 1.0 # GPT-5 and GPT-4o models only support default temperature of 1.0
41
- else
42
- 0.0 # Near-deterministic for other models (0.0 no longer universally supported)
43
- end
44
- request_params[:temperature] = temperature
45
- end
46
-
47
- # Add response format if provided by strategy
48
- if response_format
49
- request_params[:response_format] = response_format
50
- elsif @structured_outputs_enabled && signature && supports_structured_outputs?
51
- # Legacy behavior for backward compatibility
52
- response_format = DSPy::LM::Adapters::OpenAI::SchemaConverter.to_openai_format(signature)
53
- request_params[:response_format] = response_format
54
- end
55
-
56
- # Add streaming if block provided
57
- if block_given?
58
- request_params[:stream] = proc do |chunk, _bytesize|
59
- block.call(chunk) if chunk.dig("choices", 0, "delta", "content")
60
- end
61
- end
62
-
63
- begin
64
- response = @client.chat.completions.create(**request_params)
65
-
66
- if response.respond_to?(:error) && response.error
67
- raise AdapterError, "OpenAI API error: #{response.error}"
68
- end
69
-
70
- choice = response.choices.first
71
- message = choice.message
72
- content = message.content
73
- usage = response.usage
74
-
75
- # Handle structured output refusals
76
- if message.respond_to?(:refusal) && message.refusal
77
- raise AdapterError, "OpenAI refused to generate output: #{message.refusal}"
78
- end
79
-
80
- # Convert usage data to typed struct
81
- usage_struct = UsageFactory.create('openai', usage)
82
-
83
- # Create typed metadata
84
- metadata = ResponseMetadataFactory.create('openai', {
85
- model: model,
86
- response_id: response.id,
87
- created: response.created,
88
- structured_output: @structured_outputs_enabled && signature && supports_structured_outputs?,
89
- system_fingerprint: response.system_fingerprint,
90
- finish_reason: choice.finish_reason
91
- })
92
-
93
- Response.new(
94
- content: content,
95
- usage: usage_struct,
96
- metadata: metadata
97
- )
98
- rescue => e
99
- # Check for specific error types and messages
100
- error_msg = e.message.to_s
101
-
102
- # Try to parse error body if it looks like JSON
103
- error_body = if error_msg.start_with?('{')
104
- JSON.parse(error_msg) rescue nil
105
- elsif e.respond_to?(:response) && e.response
106
- e.response[:body] rescue nil
107
- end
108
-
109
- # Check for specific image-related errors
110
- if error_msg.include?('image_parse_error') || error_msg.include?('unsupported image')
111
- raise AdapterError, "Image processing failed: #{error_msg}. Ensure your image is a valid PNG, JPEG, GIF, or WebP format and under 5MB."
112
- elsif error_msg.include?('rate') && error_msg.include?('limit')
113
- raise AdapterError, "OpenAI rate limit exceeded: #{error_msg}. Please wait and try again."
114
- elsif error_msg.include?('authentication') || error_msg.include?('API key') || error_msg.include?('Unauthorized')
115
- raise AdapterError, "OpenAI authentication failed: #{error_msg}. Check your API key."
116
- elsif error_body && error_body.dig('error', 'message')
117
- raise AdapterError, "OpenAI API error: #{error_body.dig('error', 'message')}"
118
- else
119
- # Generic error handling
120
- raise AdapterError, "OpenAI adapter error: #{e.message}"
121
- end
122
- end
123
- end
124
-
125
- protected
126
-
127
- # Allow subclasses to override request params (add headers, etc)
128
- def default_request_params
129
- {
130
- model: model
131
- }
132
- end
133
-
134
- private
135
-
136
- def supports_structured_outputs?
137
- DSPy::LM::Adapters::OpenAI::SchemaConverter.supports_structured_outputs?(model)
138
- end
139
-
140
- def format_multimodal_messages(messages)
141
- messages.map do |msg|
142
- if msg[:content].is_a?(Array)
143
- # Convert multimodal content to OpenAI format
144
- formatted_content = msg[:content].map do |item|
145
- case item[:type]
146
- when 'text'
147
- { type: 'text', text: item[:text] }
148
- when 'image'
149
- # Validate image compatibility before formatting
150
- item[:image].validate_for_provider!('openai')
151
- item[:image].to_openai_format
152
- else
153
- item
154
- end
155
- end
156
-
157
- {
158
- role: msg[:role],
159
- content: formatted_content
160
- }
161
- else
162
- msg
163
- end
164
- end
165
- end
166
-
167
- # Check if model is an O1 reasoning model (includes O1, O3, O4 series)
168
- def o1_model?(model_name)
169
- model_name.match?(/^o[134](-.*)?$/)
170
- end
171
-
172
- # Handle O1 model message restrictions
173
- def handle_o1_messages(messages)
174
- messages.map do |msg|
175
- # Convert system messages to user messages for O1 models
176
- if msg[:role] == 'system'
177
- {
178
- role: 'user',
179
- content: "Instructions: #{msg[:content]}"
180
- }
181
- else
182
- msg
183
- end
184
- end
185
- end
186
- end
187
- end
188
- end
@@ -1,68 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require 'openai'
4
-
5
- module DSPy
6
- class LM
7
- class OpenrouterAdapter < OpenAIAdapter
8
- BASE_URL = 'https://openrouter.ai/api/v1'
9
-
10
- def initialize(model:, api_key: nil, structured_outputs: true, http_referrer: nil, x_title: nil)
11
- # Don't call parent's initialize, do it manually to control client creation
12
- @model = model
13
- @api_key = api_key
14
- @structured_outputs_enabled = structured_outputs
15
-
16
- @http_referrer = http_referrer
17
- @x_title = x_title
18
-
19
- validate_configuration!
20
-
21
- # Create client with custom base URL
22
- @client = OpenAI::Client.new(
23
- api_key: @api_key,
24
- base_url: BASE_URL
25
- )
26
- end
27
-
28
- def chat(messages:, signature: nil, response_format: nil, &block)
29
- # For OpenRouter, we need to be more lenient with structured outputs
30
- # as the model behind it may not fully support OpenAI's response_format spec
31
- begin
32
- super
33
- rescue => e
34
- # If structured output fails, retry with enhanced prompting
35
- if @structured_outputs_enabled && signature && e.message.include?('response_format')
36
- DSPy.logger.debug("OpenRouter structured output failed, falling back to enhanced prompting")
37
- @structured_outputs_enabled = false
38
- retry
39
- else
40
- raise
41
- end
42
- end
43
- end
44
-
45
- protected
46
-
47
- # Add any OpenRouter-specific headers to all requests
48
- def default_request_params
49
- headers = {
50
- 'X-Title' => @x_title,
51
- 'HTTP-Referer' => @http_referrer
52
- }.compact
53
-
54
- upstream_params = super
55
- upstream_params.merge!(request_options: { extra_headers: headers }) if headers.any?
56
- upstream_params
57
- end
58
-
59
- private
60
-
61
- def supports_structured_outputs?
62
- # Different models behind OpenRouter may have different capabilities
63
- # For now, we rely on whatever was passed to the constructor
64
- @structured_outputs_enabled
65
- end
66
- end
67
- end
68
- end