dspy 0.7.0 → 0.8.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.
- checksums.yaml +4 -4
- data/README.md +6 -1
- data/lib/dspy/lm/adapter.rb +2 -1
- data/lib/dspy/lm/adapter_factory.rb +7 -2
- data/lib/dspy/lm/adapters/anthropic_adapter.rb +1 -1
- data/lib/dspy/lm/adapters/openai/schema_converter.rb +269 -0
- data/lib/dspy/lm/adapters/openai_adapter.rb +29 -5
- data/lib/dspy/lm/cache_manager.rb +151 -0
- data/lib/dspy/lm/retry_handler.rb +119 -0
- data/lib/dspy/lm/strategies/anthropic_extraction_strategy.rb +78 -0
- data/lib/dspy/lm/strategies/base_strategy.rb +53 -0
- data/lib/dspy/lm/strategies/enhanced_prompting_strategy.rb +147 -0
- data/lib/dspy/lm/strategies/openai_structured_output_strategy.rb +60 -0
- data/lib/dspy/lm/strategy_selector.rb +79 -0
- data/lib/dspy/lm.rb +56 -18
- data/lib/dspy/version.rb +1 -1
- data/lib/dspy.rb +13 -0
- metadata +11 -3
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: cc32a4fe2ec14d442c2a0603b69992528ab81c67c4e61ceed6a6d337278d0220
|
4
|
+
data.tar.gz: f901d0336e0a4c912dfb37428bdd0e359b74de35b340eb769edcb5e811e257fe
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: db9477a7f7900559bb7c362104d9921d79c12b84f18ccfe0e51006adafd6e478624d20a930184e9e3dfacf9d1c88321ef54b6688f89ddf627fa0ecef3da12c53
|
7
|
+
data.tar.gz: 5ef621dfd8a8ef83f2bf0d3eabb32b07ae881636fe4c1826fddc3e544d3b5193448ff96bd28f970046d499dca69d23affa42596d41315e8c3245c4e7db5be0b3
|
data/README.md
CHANGED
@@ -25,6 +25,9 @@ The result? LLM applications that actually scale and don't break when you sneeze
|
|
25
25
|
- **Basic Optimization** - Simple prompt optimization techniques
|
26
26
|
|
27
27
|
**Production Features:**
|
28
|
+
- **Reliable JSON Extraction** - Automatic strategy selection for OpenAI structured outputs, Anthropic patterns, and fallback modes
|
29
|
+
- **Smart Retry Logic** - Progressive fallback with exponential backoff for handling transient failures
|
30
|
+
- **Performance Caching** - Schema and capability caching for faster repeated operations
|
28
31
|
- **File-based Storage** - Basic optimization result persistence
|
29
32
|
- **Multi-Platform Observability** - OpenTelemetry, New Relic, and Langfuse integration
|
30
33
|
- **Basic Instrumentation** - Event tracking and logging
|
@@ -78,7 +81,9 @@ end
|
|
78
81
|
|
79
82
|
# Configure DSPy with your LLM
|
80
83
|
DSPy.configure do |c|
|
81
|
-
c.lm = DSPy::LM.new('openai/gpt-4o-mini',
|
84
|
+
c.lm = DSPy::LM.new('openai/gpt-4o-mini',
|
85
|
+
api_key: ENV['OPENAI_API_KEY'],
|
86
|
+
structured_outputs: true) # Enable OpenAI's native JSON mode
|
82
87
|
end
|
83
88
|
|
84
89
|
# Create the predictor and run inference
|
data/lib/dspy/lm/adapter.rb
CHANGED
@@ -14,9 +14,10 @@ module DSPy
|
|
14
14
|
|
15
15
|
# Chat interface that all adapters must implement
|
16
16
|
# @param messages [Array<Hash>] Array of message hashes with :role and :content
|
17
|
+
# @param signature [DSPy::Signature, nil] Optional signature for structured outputs
|
17
18
|
# @param block [Proc] Optional streaming block
|
18
19
|
# @return [DSPy::LM::Response] Normalized response
|
19
|
-
def chat(messages:, &block)
|
20
|
+
def chat(messages:, signature: nil, &block)
|
20
21
|
raise NotImplementedError, "Subclasses must implement #chat method"
|
21
22
|
end
|
22
23
|
|
@@ -14,12 +14,17 @@ module DSPy
|
|
14
14
|
# Creates an adapter instance based on model_id
|
15
15
|
# @param model_id [String] Full model identifier (e.g., "openai/gpt-4")
|
16
16
|
# @param api_key [String] API key for the provider
|
17
|
+
# @param options [Hash] Additional adapter-specific options
|
17
18
|
# @return [DSPy::LM::Adapter] Appropriate adapter instance
|
18
|
-
def create(model_id, api_key
|
19
|
+
def create(model_id, api_key:, **options)
|
19
20
|
provider, model = parse_model_id(model_id)
|
20
21
|
adapter_class = get_adapter_class(provider)
|
21
22
|
|
22
|
-
|
23
|
+
# Pass provider-specific options
|
24
|
+
adapter_options = { model: model, api_key: api_key }
|
25
|
+
adapter_options.merge!(options) if provider == 'openai' # Only OpenAI accepts structured_outputs for now
|
26
|
+
|
27
|
+
adapter_class.new(**adapter_options)
|
23
28
|
end
|
24
29
|
|
25
30
|
private
|
@@ -11,7 +11,7 @@ module DSPy
|
|
11
11
|
@client = Anthropic::Client.new(api_key: api_key)
|
12
12
|
end
|
13
13
|
|
14
|
-
def chat(messages:, &block)
|
14
|
+
def chat(messages:, signature: nil, **extra_params, &block)
|
15
15
|
# Anthropic requires system message to be separate from messages
|
16
16
|
system_message, user_messages = extract_system_message(normalize_messages(messages))
|
17
17
|
|
@@ -0,0 +1,269 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "sorbet-runtime"
|
4
|
+
require_relative "../../cache_manager"
|
5
|
+
|
6
|
+
module DSPy
|
7
|
+
class LM
|
8
|
+
module Adapters
|
9
|
+
module OpenAI
|
10
|
+
# Converts DSPy signatures to OpenAI structured output format
|
11
|
+
class SchemaConverter
|
12
|
+
extend T::Sig
|
13
|
+
|
14
|
+
# Models that support structured outputs as of July 2025
|
15
|
+
STRUCTURED_OUTPUT_MODELS = T.let([
|
16
|
+
"gpt-4o-mini",
|
17
|
+
"gpt-4o-2024-08-06",
|
18
|
+
"gpt-4o",
|
19
|
+
"gpt-4-turbo",
|
20
|
+
"gpt-4-turbo-2024-04-09"
|
21
|
+
].freeze, T::Array[String])
|
22
|
+
|
23
|
+
sig { params(signature_class: T.class_of(DSPy::Signature), name: T.nilable(String), strict: T::Boolean).returns(T::Hash[Symbol, T.untyped]) }
|
24
|
+
def self.to_openai_format(signature_class, name: nil, strict: true)
|
25
|
+
# Build cache params from the method parameters
|
26
|
+
cache_params = { strict: strict }
|
27
|
+
cache_params[:name] = name if name
|
28
|
+
|
29
|
+
# Check cache first
|
30
|
+
cache_manager = DSPy::LM.cache_manager
|
31
|
+
cached_schema = cache_manager.get_schema(signature_class, "openai", cache_params)
|
32
|
+
|
33
|
+
if cached_schema
|
34
|
+
DSPy.logger.debug("Using cached schema for #{signature_class.name}")
|
35
|
+
return cached_schema
|
36
|
+
end
|
37
|
+
|
38
|
+
# Get the output JSON schema from the signature class
|
39
|
+
output_schema = signature_class.output_json_schema
|
40
|
+
|
41
|
+
# Build the complete schema
|
42
|
+
dspy_schema = {
|
43
|
+
"$schema": "http://json-schema.org/draft-06/schema#",
|
44
|
+
type: "object",
|
45
|
+
properties: output_schema[:properties] || {},
|
46
|
+
required: output_schema[:required] || []
|
47
|
+
}
|
48
|
+
|
49
|
+
# Generate a schema name if not provided
|
50
|
+
schema_name = name || generate_schema_name(signature_class)
|
51
|
+
|
52
|
+
# Remove the $schema field as OpenAI doesn't use it
|
53
|
+
openai_schema = dspy_schema.except(:$schema)
|
54
|
+
|
55
|
+
# Add additionalProperties: false for strict mode
|
56
|
+
if strict
|
57
|
+
openai_schema = add_additional_properties_recursively(openai_schema)
|
58
|
+
end
|
59
|
+
|
60
|
+
# Wrap in OpenAI's required format
|
61
|
+
result = {
|
62
|
+
type: "json_schema",
|
63
|
+
json_schema: {
|
64
|
+
name: schema_name,
|
65
|
+
strict: strict,
|
66
|
+
schema: openai_schema
|
67
|
+
}
|
68
|
+
}
|
69
|
+
|
70
|
+
# Cache the result with same params
|
71
|
+
cache_manager.cache_schema(signature_class, "openai", result, cache_params)
|
72
|
+
|
73
|
+
result
|
74
|
+
end
|
75
|
+
|
76
|
+
sig { params(model: String).returns(T::Boolean) }
|
77
|
+
def self.supports_structured_outputs?(model)
|
78
|
+
# Check cache first
|
79
|
+
cache_manager = DSPy::LM.cache_manager
|
80
|
+
cached_result = cache_manager.get_capability(model, "structured_outputs")
|
81
|
+
|
82
|
+
if !cached_result.nil?
|
83
|
+
DSPy.logger.debug("Using cached capability check for #{model}")
|
84
|
+
return cached_result
|
85
|
+
end
|
86
|
+
|
87
|
+
# Extract base model name without provider prefix
|
88
|
+
base_model = model.sub(/^openai\//, "")
|
89
|
+
|
90
|
+
# Check if it's a supported model or a newer version
|
91
|
+
result = STRUCTURED_OUTPUT_MODELS.any? { |supported| base_model.start_with?(supported) }
|
92
|
+
|
93
|
+
# Cache the result
|
94
|
+
cache_manager.cache_capability(model, "structured_outputs", result)
|
95
|
+
|
96
|
+
result
|
97
|
+
end
|
98
|
+
|
99
|
+
sig { params(schema: T::Hash[Symbol, T.untyped]).returns(T::Array[String]) }
|
100
|
+
def self.validate_compatibility(schema)
|
101
|
+
issues = []
|
102
|
+
|
103
|
+
# Check for deeply nested objects (OpenAI has depth limits)
|
104
|
+
depth = calculate_depth(schema)
|
105
|
+
if depth > 5
|
106
|
+
issues << "Schema depth (#{depth}) exceeds recommended limit of 5 levels"
|
107
|
+
end
|
108
|
+
|
109
|
+
# Check for unsupported JSON Schema features
|
110
|
+
if contains_pattern_properties?(schema)
|
111
|
+
issues << "Pattern properties are not supported in OpenAI structured outputs"
|
112
|
+
end
|
113
|
+
|
114
|
+
if contains_conditional_schemas?(schema)
|
115
|
+
issues << "Conditional schemas (if/then/else) are not supported"
|
116
|
+
end
|
117
|
+
|
118
|
+
issues
|
119
|
+
end
|
120
|
+
|
121
|
+
private
|
122
|
+
|
123
|
+
sig { params(schema: T::Hash[Symbol, T.untyped]).returns(T::Hash[Symbol, T.untyped]) }
|
124
|
+
def self.add_additional_properties_recursively(schema)
|
125
|
+
return schema unless schema.is_a?(Hash)
|
126
|
+
|
127
|
+
result = schema.dup
|
128
|
+
|
129
|
+
# Add additionalProperties: false if this is an object
|
130
|
+
if result[:type] == "object"
|
131
|
+
result[:additionalProperties] = false
|
132
|
+
end
|
133
|
+
|
134
|
+
# Process properties recursively
|
135
|
+
if result[:properties].is_a?(Hash)
|
136
|
+
result[:properties] = result[:properties].transform_values do |prop|
|
137
|
+
if prop.is_a?(Hash)
|
138
|
+
processed = add_additional_properties_recursively(prop)
|
139
|
+
# Special handling for arrays - ensure their items have additionalProperties if they're objects
|
140
|
+
if processed[:type] == "array" && processed[:items].is_a?(Hash)
|
141
|
+
processed[:items] = add_additional_properties_recursively(processed[:items])
|
142
|
+
end
|
143
|
+
processed
|
144
|
+
else
|
145
|
+
prop
|
146
|
+
end
|
147
|
+
end
|
148
|
+
end
|
149
|
+
|
150
|
+
# Process array items
|
151
|
+
if result[:items].is_a?(Hash)
|
152
|
+
processed_items = add_additional_properties_recursively(result[:items])
|
153
|
+
# OpenAI requires additionalProperties on all objects, even in array items
|
154
|
+
if processed_items.is_a?(Hash) && processed_items[:type] == "object" && !processed_items.key?(:additionalProperties)
|
155
|
+
processed_items[:additionalProperties] = false
|
156
|
+
end
|
157
|
+
result[:items] = processed_items
|
158
|
+
elsif result[:items].is_a?(Array)
|
159
|
+
# Handle tuple validation
|
160
|
+
result[:items] = result[:items].map do |item|
|
161
|
+
processed = item.is_a?(Hash) ? add_additional_properties_recursively(item) : item
|
162
|
+
if processed.is_a?(Hash) && processed[:type] == "object" && !processed.key?(:additionalProperties)
|
163
|
+
processed[:additionalProperties] = false
|
164
|
+
end
|
165
|
+
processed
|
166
|
+
end
|
167
|
+
end
|
168
|
+
|
169
|
+
# Process oneOf/anyOf/allOf
|
170
|
+
[:oneOf, :anyOf, :allOf].each do |key|
|
171
|
+
if result[key].is_a?(Array)
|
172
|
+
result[key] = result[key].map do |sub_schema|
|
173
|
+
sub_schema.is_a?(Hash) ? add_additional_properties_recursively(sub_schema) : sub_schema
|
174
|
+
end
|
175
|
+
end
|
176
|
+
end
|
177
|
+
|
178
|
+
result
|
179
|
+
end
|
180
|
+
|
181
|
+
sig { params(signature_class: T.class_of(DSPy::Signature)).returns(String) }
|
182
|
+
def self.generate_schema_name(signature_class)
|
183
|
+
# Use the signature class name
|
184
|
+
class_name = signature_class.name&.split("::")&.last
|
185
|
+
if class_name
|
186
|
+
class_name.gsub(/[^a-zA-Z0-9_]/, "_").downcase
|
187
|
+
else
|
188
|
+
# Fallback to a generic name
|
189
|
+
"dspy_output_#{Time.now.to_i}"
|
190
|
+
end
|
191
|
+
end
|
192
|
+
|
193
|
+
sig { params(schema: T::Hash[Symbol, T.untyped], current_depth: Integer).returns(Integer) }
|
194
|
+
def self.calculate_depth(schema, current_depth = 0)
|
195
|
+
return current_depth unless schema.is_a?(Hash)
|
196
|
+
|
197
|
+
max_depth = current_depth
|
198
|
+
|
199
|
+
# Check properties
|
200
|
+
if schema[:properties].is_a?(Hash)
|
201
|
+
schema[:properties].each_value do |prop|
|
202
|
+
if prop.is_a?(Hash)
|
203
|
+
prop_depth = calculate_depth(prop, current_depth + 1)
|
204
|
+
max_depth = [max_depth, prop_depth].max
|
205
|
+
end
|
206
|
+
end
|
207
|
+
end
|
208
|
+
|
209
|
+
# Check array items
|
210
|
+
if schema[:items].is_a?(Hash)
|
211
|
+
items_depth = calculate_depth(schema[:items], current_depth + 1)
|
212
|
+
max_depth = [max_depth, items_depth].max
|
213
|
+
end
|
214
|
+
|
215
|
+
# Check oneOf/anyOf/allOf
|
216
|
+
[:oneOf, :anyOf, :allOf].each do |key|
|
217
|
+
if schema[key].is_a?(Array)
|
218
|
+
schema[key].each do |sub_schema|
|
219
|
+
if sub_schema.is_a?(Hash)
|
220
|
+
sub_depth = calculate_depth(sub_schema, current_depth + 1)
|
221
|
+
max_depth = [max_depth, sub_depth].max
|
222
|
+
end
|
223
|
+
end
|
224
|
+
end
|
225
|
+
end
|
226
|
+
|
227
|
+
max_depth
|
228
|
+
end
|
229
|
+
|
230
|
+
sig { params(schema: T::Hash[Symbol, T.untyped]).returns(T::Boolean) }
|
231
|
+
def self.contains_pattern_properties?(schema)
|
232
|
+
return true if schema[:patternProperties]
|
233
|
+
|
234
|
+
# Recursively check nested schemas
|
235
|
+
[:properties, :items, :oneOf, :anyOf, :allOf].each do |key|
|
236
|
+
value = schema[key]
|
237
|
+
case value
|
238
|
+
when Hash
|
239
|
+
return true if contains_pattern_properties?(value)
|
240
|
+
when Array
|
241
|
+
return true if value.any? { |v| v.is_a?(Hash) && contains_pattern_properties?(v) }
|
242
|
+
end
|
243
|
+
end
|
244
|
+
|
245
|
+
false
|
246
|
+
end
|
247
|
+
|
248
|
+
sig { params(schema: T::Hash[Symbol, T.untyped]).returns(T::Boolean) }
|
249
|
+
def self.contains_conditional_schemas?(schema)
|
250
|
+
return true if schema[:if] || schema[:then] || schema[:else]
|
251
|
+
|
252
|
+
# Recursively check nested schemas
|
253
|
+
[:properties, :items, :oneOf, :anyOf, :allOf].each do |key|
|
254
|
+
value = schema[key]
|
255
|
+
case value
|
256
|
+
when Hash
|
257
|
+
return true if contains_conditional_schemas?(value)
|
258
|
+
when Array
|
259
|
+
return true if value.any? { |v| v.is_a?(Hash) && contains_conditional_schemas?(v) }
|
260
|
+
end
|
261
|
+
end
|
262
|
+
|
263
|
+
false
|
264
|
+
end
|
265
|
+
end
|
266
|
+
end
|
267
|
+
end
|
268
|
+
end
|
269
|
+
end
|
@@ -1,23 +1,34 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
3
|
require 'openai'
|
4
|
+
require_relative 'openai/schema_converter'
|
4
5
|
|
5
6
|
module DSPy
|
6
7
|
class LM
|
7
8
|
class OpenAIAdapter < Adapter
|
8
|
-
def initialize(model:, api_key:)
|
9
|
-
super
|
9
|
+
def initialize(model:, api_key:, structured_outputs: false)
|
10
|
+
super(model: model, api_key: api_key)
|
10
11
|
validate_api_key!(api_key, 'openai')
|
11
12
|
@client = OpenAI::Client.new(api_key: api_key)
|
13
|
+
@structured_outputs_enabled = structured_outputs
|
12
14
|
end
|
13
15
|
|
14
|
-
def chat(messages:, &block)
|
16
|
+
def chat(messages:, signature: nil, response_format: nil, &block)
|
15
17
|
request_params = {
|
16
18
|
model: model,
|
17
19
|
messages: normalize_messages(messages),
|
18
20
|
temperature: 0.0 # DSPy default for deterministic responses
|
19
21
|
}
|
20
22
|
|
23
|
+
# Add response format if provided by strategy
|
24
|
+
if response_format
|
25
|
+
request_params[:response_format] = response_format
|
26
|
+
elsif @structured_outputs_enabled && signature && supports_structured_outputs?
|
27
|
+
# Legacy behavior for backward compatibility
|
28
|
+
response_format = DSPy::LM::Adapters::OpenAI::SchemaConverter.to_openai_format(signature)
|
29
|
+
request_params[:response_format] = response_format
|
30
|
+
end
|
31
|
+
|
21
32
|
# Add streaming if block provided
|
22
33
|
if block_given?
|
23
34
|
request_params[:stream] = proc do |chunk, _bytesize|
|
@@ -32,9 +43,15 @@ module DSPy
|
|
32
43
|
raise AdapterError, "OpenAI API error: #{response.error}"
|
33
44
|
end
|
34
45
|
|
35
|
-
|
46
|
+
message = response.choices.first.message
|
47
|
+
content = message.content
|
36
48
|
usage = response.usage
|
37
49
|
|
50
|
+
# Handle structured output refusals
|
51
|
+
if message.respond_to?(:refusal) && message.refusal
|
52
|
+
raise AdapterError, "OpenAI refused to generate output: #{message.refusal}"
|
53
|
+
end
|
54
|
+
|
38
55
|
Response.new(
|
39
56
|
content: content,
|
40
57
|
usage: usage.respond_to?(:to_h) ? usage.to_h : usage,
|
@@ -42,13 +59,20 @@ module DSPy
|
|
42
59
|
provider: 'openai',
|
43
60
|
model: model,
|
44
61
|
response_id: response.id,
|
45
|
-
created: response.created
|
62
|
+
created: response.created,
|
63
|
+
structured_output: @structured_outputs_enabled && signature && supports_structured_outputs?
|
46
64
|
}
|
47
65
|
)
|
48
66
|
rescue => e
|
49
67
|
raise AdapterError, "OpenAI adapter error: #{e.message}"
|
50
68
|
end
|
51
69
|
end
|
70
|
+
|
71
|
+
private
|
72
|
+
|
73
|
+
def supports_structured_outputs?
|
74
|
+
DSPy::LM::Adapters::OpenAI::SchemaConverter.supports_structured_outputs?(model)
|
75
|
+
end
|
52
76
|
end
|
53
77
|
end
|
54
78
|
end
|
@@ -0,0 +1,151 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "sorbet-runtime"
|
4
|
+
|
5
|
+
module DSPy
|
6
|
+
class LM
|
7
|
+
# Manages caching for schemas and capability detection
|
8
|
+
class CacheManager
|
9
|
+
extend T::Sig
|
10
|
+
|
11
|
+
# Cache entry with TTL
|
12
|
+
class CacheEntry < T::Struct
|
13
|
+
extend T::Sig
|
14
|
+
|
15
|
+
const :value, T.untyped
|
16
|
+
const :expires_at, Time
|
17
|
+
|
18
|
+
sig { returns(T::Boolean) }
|
19
|
+
def expired?
|
20
|
+
Time.now > expires_at
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
24
|
+
DEFAULT_TTL = 3600 # 1 hour
|
25
|
+
|
26
|
+
sig { void }
|
27
|
+
def initialize
|
28
|
+
@schema_cache = {}
|
29
|
+
@capability_cache = {}
|
30
|
+
@mutex = Mutex.new
|
31
|
+
end
|
32
|
+
|
33
|
+
# Cache a schema for a signature class
|
34
|
+
sig { params(signature_class: T.class_of(DSPy::Signature), provider: String, schema: T.untyped, cache_params: T::Hash[Symbol, T.untyped]).void }
|
35
|
+
def cache_schema(signature_class, provider, schema, cache_params = {})
|
36
|
+
key = schema_key(signature_class, provider, cache_params)
|
37
|
+
|
38
|
+
@mutex.synchronize do
|
39
|
+
@schema_cache[key] = CacheEntry.new(
|
40
|
+
value: schema,
|
41
|
+
expires_at: Time.now + DEFAULT_TTL
|
42
|
+
)
|
43
|
+
end
|
44
|
+
|
45
|
+
DSPy.logger.debug("Cached schema for #{signature_class.name} (#{provider})")
|
46
|
+
end
|
47
|
+
|
48
|
+
# Get cached schema if available
|
49
|
+
sig { params(signature_class: T.class_of(DSPy::Signature), provider: String, cache_params: T::Hash[Symbol, T.untyped]).returns(T.nilable(T.untyped)) }
|
50
|
+
def get_schema(signature_class, provider, cache_params = {})
|
51
|
+
key = schema_key(signature_class, provider, cache_params)
|
52
|
+
|
53
|
+
@mutex.synchronize do
|
54
|
+
entry = @schema_cache[key]
|
55
|
+
|
56
|
+
if entry.nil?
|
57
|
+
nil
|
58
|
+
elsif entry.expired?
|
59
|
+
@schema_cache.delete(key)
|
60
|
+
nil
|
61
|
+
else
|
62
|
+
entry.value
|
63
|
+
end
|
64
|
+
end
|
65
|
+
end
|
66
|
+
|
67
|
+
# Cache capability detection result
|
68
|
+
sig { params(model: String, capability: String, result: T::Boolean).void }
|
69
|
+
def cache_capability(model, capability, result)
|
70
|
+
key = capability_key(model, capability)
|
71
|
+
|
72
|
+
@mutex.synchronize do
|
73
|
+
@capability_cache[key] = CacheEntry.new(
|
74
|
+
value: result,
|
75
|
+
expires_at: Time.now + DEFAULT_TTL * 24 # Capabilities change less frequently
|
76
|
+
)
|
77
|
+
end
|
78
|
+
|
79
|
+
DSPy.logger.debug("Cached capability #{capability} for #{model}: #{result}")
|
80
|
+
end
|
81
|
+
|
82
|
+
# Get cached capability if available
|
83
|
+
sig { params(model: String, capability: String).returns(T.nilable(T::Boolean)) }
|
84
|
+
def get_capability(model, capability)
|
85
|
+
key = capability_key(model, capability)
|
86
|
+
|
87
|
+
@mutex.synchronize do
|
88
|
+
entry = @capability_cache[key]
|
89
|
+
|
90
|
+
if entry.nil?
|
91
|
+
nil
|
92
|
+
elsif entry.expired?
|
93
|
+
@capability_cache.delete(key)
|
94
|
+
nil
|
95
|
+
else
|
96
|
+
entry.value
|
97
|
+
end
|
98
|
+
end
|
99
|
+
end
|
100
|
+
|
101
|
+
# Clear all caches
|
102
|
+
sig { void }
|
103
|
+
def clear!
|
104
|
+
@mutex.synchronize do
|
105
|
+
@schema_cache.clear
|
106
|
+
@capability_cache.clear
|
107
|
+
end
|
108
|
+
|
109
|
+
DSPy.logger.debug("Cleared all caches")
|
110
|
+
end
|
111
|
+
|
112
|
+
# Get cache statistics
|
113
|
+
sig { returns(T::Hash[Symbol, Integer]) }
|
114
|
+
def stats
|
115
|
+
@mutex.synchronize do
|
116
|
+
{
|
117
|
+
schema_entries: @schema_cache.size,
|
118
|
+
capability_entries: @capability_cache.size,
|
119
|
+
total_entries: @schema_cache.size + @capability_cache.size
|
120
|
+
}
|
121
|
+
end
|
122
|
+
end
|
123
|
+
|
124
|
+
private
|
125
|
+
|
126
|
+
sig { params(signature_class: T.class_of(DSPy::Signature), provider: String, cache_params: T::Hash[Symbol, T.untyped]).returns(String) }
|
127
|
+
def schema_key(signature_class, provider, cache_params = {})
|
128
|
+
params_str = cache_params.sort.map { |k, v| "#{k}:#{v}" }.join(":")
|
129
|
+
base_key = "schema:#{provider}:#{signature_class.name}"
|
130
|
+
params_str.empty? ? base_key : "#{base_key}:#{params_str}"
|
131
|
+
end
|
132
|
+
|
133
|
+
sig { params(model: String, capability: String).returns(String) }
|
134
|
+
def capability_key(model, capability)
|
135
|
+
"capability:#{model}:#{capability}"
|
136
|
+
end
|
137
|
+
end
|
138
|
+
|
139
|
+
# Global cache instance
|
140
|
+
@cache_manager = T.let(nil, T.nilable(CacheManager))
|
141
|
+
|
142
|
+
class << self
|
143
|
+
extend T::Sig
|
144
|
+
|
145
|
+
sig { returns(CacheManager) }
|
146
|
+
def cache_manager
|
147
|
+
@cache_manager ||= CacheManager.new
|
148
|
+
end
|
149
|
+
end
|
150
|
+
end
|
151
|
+
end
|
@@ -0,0 +1,119 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "sorbet-runtime"
|
4
|
+
|
5
|
+
module DSPy
|
6
|
+
class LM
|
7
|
+
# Handles retry logic with progressive fallback strategies
|
8
|
+
class RetryHandler
|
9
|
+
extend T::Sig
|
10
|
+
|
11
|
+
MAX_RETRIES = 3
|
12
|
+
BACKOFF_BASE = 0.5 # seconds
|
13
|
+
|
14
|
+
sig { params(adapter: DSPy::LM::Adapter, signature_class: T.class_of(DSPy::Signature)).void }
|
15
|
+
def initialize(adapter, signature_class)
|
16
|
+
@adapter = adapter
|
17
|
+
@signature_class = signature_class
|
18
|
+
@attempt = 0
|
19
|
+
end
|
20
|
+
|
21
|
+
# Execute a block with retry logic and progressive fallback
|
22
|
+
sig do
|
23
|
+
type_parameters(:T)
|
24
|
+
.params(
|
25
|
+
initial_strategy: Strategies::BaseStrategy,
|
26
|
+
block: T.proc.params(strategy: Strategies::BaseStrategy).returns(T.type_parameter(:T))
|
27
|
+
)
|
28
|
+
.returns(T.type_parameter(:T))
|
29
|
+
end
|
30
|
+
def with_retry(initial_strategy, &block)
|
31
|
+
strategies = build_fallback_chain(initial_strategy)
|
32
|
+
last_error = nil
|
33
|
+
|
34
|
+
strategies.each do |strategy|
|
35
|
+
retry_count = 0
|
36
|
+
|
37
|
+
begin
|
38
|
+
@attempt += 1
|
39
|
+
DSPy.logger.debug("Attempting with strategy: #{strategy.name} (attempt #{@attempt})")
|
40
|
+
|
41
|
+
result = yield(strategy)
|
42
|
+
|
43
|
+
# Success! Reset attempt counter for next time
|
44
|
+
@attempt = 0
|
45
|
+
return result
|
46
|
+
|
47
|
+
rescue JSON::ParserError, StandardError => e
|
48
|
+
last_error = e
|
49
|
+
|
50
|
+
# Let strategy handle the error first
|
51
|
+
if strategy.handle_error(e)
|
52
|
+
DSPy.logger.info("Strategy #{strategy.name} handled error, will try next strategy")
|
53
|
+
next # Try next strategy
|
54
|
+
end
|
55
|
+
|
56
|
+
# Try retrying with the same strategy
|
57
|
+
if retry_count < max_retries_for_strategy(strategy)
|
58
|
+
retry_count += 1
|
59
|
+
backoff_time = calculate_backoff(retry_count)
|
60
|
+
|
61
|
+
DSPy.logger.warn(
|
62
|
+
"Retrying #{strategy.name} after error (attempt #{retry_count}/#{max_retries_for_strategy(strategy)}): #{e.message}"
|
63
|
+
)
|
64
|
+
|
65
|
+
sleep(backoff_time) if backoff_time > 0
|
66
|
+
retry
|
67
|
+
else
|
68
|
+
DSPy.logger.info("Max retries reached for #{strategy.name}, trying next strategy")
|
69
|
+
next # Try next strategy
|
70
|
+
end
|
71
|
+
end
|
72
|
+
end
|
73
|
+
|
74
|
+
# All strategies exhausted
|
75
|
+
DSPy.logger.error("All strategies exhausted after #{@attempt} total attempts")
|
76
|
+
raise last_error || StandardError.new("All JSON extraction strategies failed")
|
77
|
+
end
|
78
|
+
|
79
|
+
private
|
80
|
+
|
81
|
+
# Build a chain of strategies to try in order
|
82
|
+
sig { params(initial_strategy: Strategies::BaseStrategy).returns(T::Array[Strategies::BaseStrategy]) }
|
83
|
+
def build_fallback_chain(initial_strategy)
|
84
|
+
selector = StrategySelector.new(@adapter, @signature_class)
|
85
|
+
all_strategies = selector.available_strategies.sort_by(&:priority).reverse
|
86
|
+
|
87
|
+
# Start with the requested strategy, then try others
|
88
|
+
chain = [initial_strategy]
|
89
|
+
chain.concat(all_strategies.reject { |s| s.name == initial_strategy.name })
|
90
|
+
|
91
|
+
chain
|
92
|
+
end
|
93
|
+
|
94
|
+
# Different strategies get different retry counts
|
95
|
+
sig { params(strategy: Strategies::BaseStrategy).returns(Integer) }
|
96
|
+
def max_retries_for_strategy(strategy)
|
97
|
+
case strategy.name
|
98
|
+
when "openai_structured_output"
|
99
|
+
1 # Structured outputs rarely benefit from retries
|
100
|
+
when "anthropic_extraction"
|
101
|
+
2 # Anthropic can be a bit more variable
|
102
|
+
else
|
103
|
+
MAX_RETRIES # Enhanced prompting might need more attempts
|
104
|
+
end
|
105
|
+
end
|
106
|
+
|
107
|
+
# Calculate exponential backoff with jitter
|
108
|
+
sig { params(attempt: Integer).returns(Float) }
|
109
|
+
def calculate_backoff(attempt)
|
110
|
+
return 0.0 if DSPy.config.test_mode # No sleep in tests
|
111
|
+
|
112
|
+
base_delay = BACKOFF_BASE * (2 ** (attempt - 1))
|
113
|
+
jitter = rand * 0.1 * base_delay
|
114
|
+
|
115
|
+
[base_delay + jitter, 10.0].min # Cap at 10 seconds
|
116
|
+
end
|
117
|
+
end
|
118
|
+
end
|
119
|
+
end
|
@@ -0,0 +1,78 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "base_strategy"
|
4
|
+
|
5
|
+
module DSPy
|
6
|
+
class LM
|
7
|
+
module Strategies
|
8
|
+
# Strategy for using Anthropic's enhanced JSON extraction patterns
|
9
|
+
class AnthropicExtractionStrategy < BaseStrategy
|
10
|
+
extend T::Sig
|
11
|
+
|
12
|
+
sig { override.returns(T::Boolean) }
|
13
|
+
def available?
|
14
|
+
adapter.is_a?(DSPy::LM::AnthropicAdapter)
|
15
|
+
end
|
16
|
+
|
17
|
+
sig { override.returns(Integer) }
|
18
|
+
def priority
|
19
|
+
90 # High priority - Anthropic's extraction is very reliable
|
20
|
+
end
|
21
|
+
|
22
|
+
sig { override.returns(String) }
|
23
|
+
def name
|
24
|
+
"anthropic_extraction"
|
25
|
+
end
|
26
|
+
|
27
|
+
sig { override.params(messages: T::Array[T::Hash[Symbol, String]], request_params: T::Hash[Symbol, T.untyped]).void }
|
28
|
+
def prepare_request(messages, request_params)
|
29
|
+
# Anthropic adapter already handles JSON optimization in prepare_messages_for_json
|
30
|
+
# No additional preparation needed here
|
31
|
+
end
|
32
|
+
|
33
|
+
sig { override.params(response: DSPy::LM::Response).returns(T.nilable(String)) }
|
34
|
+
def extract_json(response)
|
35
|
+
# Use Anthropic's specialized extraction method if available
|
36
|
+
if adapter.respond_to?(:extract_json_from_response)
|
37
|
+
adapter.extract_json_from_response(response.content)
|
38
|
+
else
|
39
|
+
# Fallback to basic extraction
|
40
|
+
extract_json_fallback(response.content)
|
41
|
+
end
|
42
|
+
end
|
43
|
+
|
44
|
+
private
|
45
|
+
|
46
|
+
sig { params(content: T.nilable(String)).returns(T.nilable(String)) }
|
47
|
+
def extract_json_fallback(content)
|
48
|
+
return nil if content.nil?
|
49
|
+
|
50
|
+
# Try the 4 patterns Anthropic adapter uses
|
51
|
+
# Pattern 1: ```json blocks
|
52
|
+
if content.include?('```json')
|
53
|
+
return content.split('```json').last.split('```').first.strip
|
54
|
+
end
|
55
|
+
|
56
|
+
# Pattern 2: ## Output values header
|
57
|
+
if content.include?('## Output values')
|
58
|
+
json_part = content.split('## Output values').last
|
59
|
+
if json_part.include?('```')
|
60
|
+
return json_part.split('```')[1].strip
|
61
|
+
end
|
62
|
+
end
|
63
|
+
|
64
|
+
# Pattern 3: Generic code blocks
|
65
|
+
if content.include?('```')
|
66
|
+
code_block = content.split('```')[1]
|
67
|
+
if code_block && (code_block.strip.start_with?('{') || code_block.strip.start_with?('['))
|
68
|
+
return code_block.strip
|
69
|
+
end
|
70
|
+
end
|
71
|
+
|
72
|
+
# Pattern 4: Already valid JSON
|
73
|
+
content.strip if content.strip.start_with?('{') || content.strip.start_with?('[')
|
74
|
+
end
|
75
|
+
end
|
76
|
+
end
|
77
|
+
end
|
78
|
+
end
|
@@ -0,0 +1,53 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "sorbet-runtime"
|
4
|
+
|
5
|
+
module DSPy
|
6
|
+
class LM
|
7
|
+
module Strategies
|
8
|
+
# Base class for JSON extraction strategies
|
9
|
+
class BaseStrategy
|
10
|
+
extend T::Sig
|
11
|
+
extend T::Helpers
|
12
|
+
abstract!
|
13
|
+
|
14
|
+
sig { params(adapter: DSPy::LM::Adapter, signature_class: T.class_of(DSPy::Signature)).void }
|
15
|
+
def initialize(adapter, signature_class)
|
16
|
+
@adapter = adapter
|
17
|
+
@signature_class = signature_class
|
18
|
+
end
|
19
|
+
|
20
|
+
# Check if this strategy is available for the given adapter/model
|
21
|
+
sig { abstract.returns(T::Boolean) }
|
22
|
+
def available?; end
|
23
|
+
|
24
|
+
# Priority for this strategy (higher = preferred)
|
25
|
+
sig { abstract.returns(Integer) }
|
26
|
+
def priority; end
|
27
|
+
|
28
|
+
# Name of the strategy for logging/debugging
|
29
|
+
sig { abstract.returns(String) }
|
30
|
+
def name; end
|
31
|
+
|
32
|
+
# Prepare the request for JSON extraction
|
33
|
+
sig { abstract.params(messages: T::Array[T::Hash[Symbol, String]], request_params: T::Hash[Symbol, T.untyped]).void }
|
34
|
+
def prepare_request(messages, request_params); end
|
35
|
+
|
36
|
+
# Extract JSON from the response
|
37
|
+
sig { abstract.params(response: DSPy::LM::Response).returns(T.nilable(String)) }
|
38
|
+
def extract_json(response); end
|
39
|
+
|
40
|
+
# Handle errors specific to this strategy
|
41
|
+
sig { params(error: StandardError).returns(T::Boolean) }
|
42
|
+
def handle_error(error)
|
43
|
+
# By default, don't handle errors - let them propagate
|
44
|
+
false
|
45
|
+
end
|
46
|
+
|
47
|
+
protected
|
48
|
+
|
49
|
+
attr_reader :adapter, :signature_class
|
50
|
+
end
|
51
|
+
end
|
52
|
+
end
|
53
|
+
end
|
@@ -0,0 +1,147 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "base_strategy"
|
4
|
+
|
5
|
+
module DSPy
|
6
|
+
class LM
|
7
|
+
module Strategies
|
8
|
+
# Enhanced prompting strategy that works with any LLM
|
9
|
+
# Adds explicit JSON formatting instructions to improve reliability
|
10
|
+
class EnhancedPromptingStrategy < BaseStrategy
|
11
|
+
extend T::Sig
|
12
|
+
|
13
|
+
sig { override.returns(T::Boolean) }
|
14
|
+
def available?
|
15
|
+
# This strategy is always available as a fallback
|
16
|
+
true
|
17
|
+
end
|
18
|
+
|
19
|
+
sig { override.returns(Integer) }
|
20
|
+
def priority
|
21
|
+
50 # Medium priority - use when native methods aren't available
|
22
|
+
end
|
23
|
+
|
24
|
+
sig { override.returns(String) }
|
25
|
+
def name
|
26
|
+
"enhanced_prompting"
|
27
|
+
end
|
28
|
+
|
29
|
+
sig { override.params(messages: T::Array[T::Hash[Symbol, String]], request_params: T::Hash[Symbol, T.untyped]).void }
|
30
|
+
def prepare_request(messages, request_params)
|
31
|
+
# Enhance the user message with explicit JSON instructions
|
32
|
+
return if messages.empty?
|
33
|
+
|
34
|
+
# Get the output schema
|
35
|
+
output_schema = signature_class.output_json_schema
|
36
|
+
|
37
|
+
# Find the last user message
|
38
|
+
last_user_idx = messages.rindex { |msg| msg[:role] == "user" }
|
39
|
+
return unless last_user_idx
|
40
|
+
|
41
|
+
# Add JSON formatting instructions
|
42
|
+
original_content = messages[last_user_idx][:content]
|
43
|
+
enhanced_content = enhance_prompt_with_json_instructions(original_content, output_schema)
|
44
|
+
messages[last_user_idx][:content] = enhanced_content
|
45
|
+
|
46
|
+
# Add system instructions if no system message exists
|
47
|
+
if messages.none? { |msg| msg[:role] == "system" }
|
48
|
+
messages.unshift({
|
49
|
+
role: "system",
|
50
|
+
content: "You are a helpful assistant that always responds with valid JSON when requested."
|
51
|
+
})
|
52
|
+
end
|
53
|
+
end
|
54
|
+
|
55
|
+
sig { override.params(response: DSPy::LM::Response).returns(T.nilable(String)) }
|
56
|
+
def extract_json(response)
|
57
|
+
return nil if response.content.nil?
|
58
|
+
|
59
|
+
content = response.content.strip
|
60
|
+
|
61
|
+
# Try multiple extraction patterns
|
62
|
+
# 1. Check for markdown code blocks
|
63
|
+
if content.include?('```json')
|
64
|
+
json_content = content.split('```json').last.split('```').first.strip
|
65
|
+
return json_content if valid_json?(json_content)
|
66
|
+
elsif content.include?('```')
|
67
|
+
code_block = content.split('```')[1]
|
68
|
+
if code_block
|
69
|
+
json_content = code_block.strip
|
70
|
+
return json_content if valid_json?(json_content)
|
71
|
+
end
|
72
|
+
end
|
73
|
+
|
74
|
+
# 2. Check if the entire response is JSON
|
75
|
+
return content if valid_json?(content)
|
76
|
+
|
77
|
+
# 3. Look for JSON-like structures in the content
|
78
|
+
json_match = content.match(/\{[\s\S]*\}|\[[\s\S]*\]/)
|
79
|
+
if json_match
|
80
|
+
json_content = json_match[0]
|
81
|
+
return json_content if valid_json?(json_content)
|
82
|
+
end
|
83
|
+
|
84
|
+
nil
|
85
|
+
end
|
86
|
+
|
87
|
+
private
|
88
|
+
|
89
|
+
sig { params(prompt: String, schema: T::Hash[Symbol, T.untyped]).returns(String) }
|
90
|
+
def enhance_prompt_with_json_instructions(prompt, schema)
|
91
|
+
json_example = generate_example_from_schema(schema)
|
92
|
+
|
93
|
+
<<~ENHANCED
|
94
|
+
#{prompt}
|
95
|
+
|
96
|
+
IMPORTANT: You must respond with valid JSON that matches this structure:
|
97
|
+
```json
|
98
|
+
#{JSON.pretty_generate(json_example)}
|
99
|
+
```
|
100
|
+
|
101
|
+
Required fields: #{schema[:required]&.join(', ') || 'none'}
|
102
|
+
|
103
|
+
Ensure your response:
|
104
|
+
1. Is valid JSON (properly quoted strings, no trailing commas)
|
105
|
+
2. Includes all required fields
|
106
|
+
3. Uses the correct data types for each field
|
107
|
+
4. Is wrapped in ```json``` markdown code blocks
|
108
|
+
ENHANCED
|
109
|
+
end
|
110
|
+
|
111
|
+
sig { params(schema: T::Hash[Symbol, T.untyped]).returns(T::Hash[String, T.untyped]) }
|
112
|
+
def generate_example_from_schema(schema)
|
113
|
+
return {} unless schema[:properties]
|
114
|
+
|
115
|
+
example = {}
|
116
|
+
schema[:properties].each do |field_name, field_schema|
|
117
|
+
example[field_name.to_s] = case field_schema[:type]
|
118
|
+
when "string"
|
119
|
+
field_schema[:description] || "example string"
|
120
|
+
when "integer"
|
121
|
+
42
|
122
|
+
when "number"
|
123
|
+
3.14
|
124
|
+
when "boolean"
|
125
|
+
true
|
126
|
+
when "array"
|
127
|
+
["example item"]
|
128
|
+
when "object"
|
129
|
+
{ "nested" => "object" }
|
130
|
+
else
|
131
|
+
"example value"
|
132
|
+
end
|
133
|
+
end
|
134
|
+
example
|
135
|
+
end
|
136
|
+
|
137
|
+
sig { params(content: String).returns(T::Boolean) }
|
138
|
+
def valid_json?(content)
|
139
|
+
JSON.parse(content)
|
140
|
+
true
|
141
|
+
rescue JSON::ParserError
|
142
|
+
false
|
143
|
+
end
|
144
|
+
end
|
145
|
+
end
|
146
|
+
end
|
147
|
+
end
|
@@ -0,0 +1,60 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "base_strategy"
|
4
|
+
|
5
|
+
module DSPy
|
6
|
+
class LM
|
7
|
+
module Strategies
|
8
|
+
# Strategy for using OpenAI's native structured output feature
|
9
|
+
class OpenAIStructuredOutputStrategy < BaseStrategy
|
10
|
+
extend T::Sig
|
11
|
+
|
12
|
+
sig { override.returns(T::Boolean) }
|
13
|
+
def available?
|
14
|
+
# Check if adapter is OpenAI and supports structured outputs
|
15
|
+
return false unless adapter.is_a?(DSPy::LM::OpenAIAdapter)
|
16
|
+
return false unless adapter.instance_variable_get(:@structured_outputs_enabled)
|
17
|
+
|
18
|
+
DSPy::LM::Adapters::OpenAI::SchemaConverter.supports_structured_outputs?(adapter.model)
|
19
|
+
end
|
20
|
+
|
21
|
+
sig { override.returns(Integer) }
|
22
|
+
def priority
|
23
|
+
100 # Highest priority - native structured outputs are most reliable
|
24
|
+
end
|
25
|
+
|
26
|
+
sig { override.returns(String) }
|
27
|
+
def name
|
28
|
+
"openai_structured_output"
|
29
|
+
end
|
30
|
+
|
31
|
+
sig { override.params(messages: T::Array[T::Hash[Symbol, String]], request_params: T::Hash[Symbol, T.untyped]).void }
|
32
|
+
def prepare_request(messages, request_params)
|
33
|
+
# Add structured output format to request
|
34
|
+
response_format = DSPy::LM::Adapters::OpenAI::SchemaConverter.to_openai_format(signature_class)
|
35
|
+
request_params[:response_format] = response_format
|
36
|
+
end
|
37
|
+
|
38
|
+
sig { override.params(response: DSPy::LM::Response).returns(T.nilable(String)) }
|
39
|
+
def extract_json(response)
|
40
|
+
# With structured outputs, the response should already be valid JSON
|
41
|
+
# Just return the content as-is
|
42
|
+
response.content
|
43
|
+
end
|
44
|
+
|
45
|
+
sig { override.params(error: StandardError).returns(T::Boolean) }
|
46
|
+
def handle_error(error)
|
47
|
+
# Handle OpenAI-specific structured output errors
|
48
|
+
if error.message.include?("response_format") || error.message.include?("Invalid schema")
|
49
|
+
# Log the error and return true to indicate we handled it
|
50
|
+
# This allows fallback to another strategy
|
51
|
+
DSPy.logger.warn("OpenAI structured output failed: #{error.message}")
|
52
|
+
true
|
53
|
+
else
|
54
|
+
false
|
55
|
+
end
|
56
|
+
end
|
57
|
+
end
|
58
|
+
end
|
59
|
+
end
|
60
|
+
end
|
@@ -0,0 +1,79 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "sorbet-runtime"
|
4
|
+
require_relative "strategies/base_strategy"
|
5
|
+
require_relative "strategies/openai_structured_output_strategy"
|
6
|
+
require_relative "strategies/anthropic_extraction_strategy"
|
7
|
+
require_relative "strategies/enhanced_prompting_strategy"
|
8
|
+
|
9
|
+
module DSPy
|
10
|
+
class LM
|
11
|
+
# Selects the best JSON extraction strategy based on the adapter and capabilities
|
12
|
+
class StrategySelector
|
13
|
+
extend T::Sig
|
14
|
+
|
15
|
+
# Available strategies in order of registration
|
16
|
+
STRATEGIES = [
|
17
|
+
Strategies::OpenAIStructuredOutputStrategy,
|
18
|
+
Strategies::AnthropicExtractionStrategy,
|
19
|
+
Strategies::EnhancedPromptingStrategy
|
20
|
+
].freeze
|
21
|
+
|
22
|
+
sig { params(adapter: DSPy::LM::Adapter, signature_class: T.class_of(DSPy::Signature)).void }
|
23
|
+
def initialize(adapter, signature_class)
|
24
|
+
@adapter = adapter
|
25
|
+
@signature_class = signature_class
|
26
|
+
@strategies = build_strategies
|
27
|
+
end
|
28
|
+
|
29
|
+
# Select the best available strategy
|
30
|
+
sig { returns(Strategies::BaseStrategy) }
|
31
|
+
def select
|
32
|
+
# Allow manual override via configuration
|
33
|
+
if DSPy.config.structured_outputs.strategy
|
34
|
+
strategy = find_strategy_by_name(DSPy.config.structured_outputs.strategy)
|
35
|
+
return strategy if strategy&.available?
|
36
|
+
|
37
|
+
DSPy.logger.warn("Requested strategy '#{DSPy.config.structured_outputs.strategy}' is not available")
|
38
|
+
end
|
39
|
+
|
40
|
+
# Select the highest priority available strategy
|
41
|
+
available_strategies = @strategies.select(&:available?)
|
42
|
+
|
43
|
+
if available_strategies.empty?
|
44
|
+
raise "No JSON extraction strategies available for #{@adapter.class}"
|
45
|
+
end
|
46
|
+
|
47
|
+
selected = available_strategies.max_by(&:priority)
|
48
|
+
|
49
|
+
DSPy.logger.debug("Selected JSON extraction strategy: #{selected.name}")
|
50
|
+
selected
|
51
|
+
end
|
52
|
+
|
53
|
+
# Get all available strategies
|
54
|
+
sig { returns(T::Array[Strategies::BaseStrategy]) }
|
55
|
+
def available_strategies
|
56
|
+
@strategies.select(&:available?)
|
57
|
+
end
|
58
|
+
|
59
|
+
# Check if a specific strategy is available
|
60
|
+
sig { params(strategy_name: String).returns(T::Boolean) }
|
61
|
+
def strategy_available?(strategy_name)
|
62
|
+
strategy = find_strategy_by_name(strategy_name)
|
63
|
+
strategy&.available? || false
|
64
|
+
end
|
65
|
+
|
66
|
+
private
|
67
|
+
|
68
|
+
sig { returns(T::Array[Strategies::BaseStrategy]) }
|
69
|
+
def build_strategies
|
70
|
+
STRATEGIES.map { |klass| klass.new(@adapter, @signature_class) }
|
71
|
+
end
|
72
|
+
|
73
|
+
sig { params(name: String).returns(T.nilable(Strategies::BaseStrategy)) }
|
74
|
+
def find_strategy_by_name(name)
|
75
|
+
@strategies.find { |s| s.name == name }
|
76
|
+
end
|
77
|
+
end
|
78
|
+
end
|
79
|
+
end
|
data/lib/dspy/lm.rb
CHANGED
@@ -14,19 +14,23 @@ require_relative 'instrumentation/token_tracker'
|
|
14
14
|
require_relative 'lm/adapters/openai_adapter'
|
15
15
|
require_relative 'lm/adapters/anthropic_adapter'
|
16
16
|
|
17
|
+
# Load strategy system
|
18
|
+
require_relative 'lm/strategy_selector'
|
19
|
+
require_relative 'lm/retry_handler'
|
20
|
+
|
17
21
|
module DSPy
|
18
22
|
class LM
|
19
23
|
attr_reader :model_id, :api_key, :model, :provider, :adapter
|
20
24
|
|
21
|
-
def initialize(model_id, api_key: nil)
|
25
|
+
def initialize(model_id, api_key: nil, **options)
|
22
26
|
@model_id = model_id
|
23
27
|
@api_key = api_key
|
24
28
|
|
25
29
|
# Parse provider and model from model_id
|
26
30
|
@provider, @model = parse_model_id(model_id)
|
27
31
|
|
28
|
-
# Create appropriate adapter
|
29
|
-
@adapter = AdapterFactory.create(model_id, api_key: api_key)
|
32
|
+
# Create appropriate adapter with options
|
33
|
+
@adapter = AdapterFactory.create(model_id, api_key: api_key, **options)
|
30
34
|
end
|
31
35
|
|
32
36
|
def chat(inference_module, input_values, &block)
|
@@ -54,7 +58,7 @@ module DSPy
|
|
54
58
|
adapter_class: adapter.class.name,
|
55
59
|
input_size: input_size
|
56
60
|
}) do
|
57
|
-
|
61
|
+
chat_with_strategy(messages, signature_class, &block)
|
58
62
|
end
|
59
63
|
|
60
64
|
# Extract actual token usage from response (more accurate than estimation)
|
@@ -79,7 +83,7 @@ module DSPy
|
|
79
83
|
end
|
80
84
|
else
|
81
85
|
# Consolidated mode: execute without nested instrumentation
|
82
|
-
response =
|
86
|
+
response = chat_with_strategy(messages, signature_class, &block)
|
83
87
|
token_usage = Instrumentation::TokenTracker.extract_token_usage(response, provider)
|
84
88
|
parsed_result = parse_response(response, input_values, signature_class)
|
85
89
|
end
|
@@ -89,6 +93,53 @@ module DSPy
|
|
89
93
|
|
90
94
|
private
|
91
95
|
|
96
|
+
def chat_with_strategy(messages, signature_class, &block)
|
97
|
+
# Select the best strategy for JSON extraction
|
98
|
+
strategy_selector = StrategySelector.new(adapter, signature_class)
|
99
|
+
initial_strategy = strategy_selector.select
|
100
|
+
|
101
|
+
if DSPy.config.structured_outputs.retry_enabled && signature_class
|
102
|
+
# Use retry handler for JSON responses
|
103
|
+
retry_handler = RetryHandler.new(adapter, signature_class)
|
104
|
+
|
105
|
+
retry_handler.with_retry(initial_strategy) do |strategy|
|
106
|
+
execute_chat_with_strategy(messages, signature_class, strategy, &block)
|
107
|
+
end
|
108
|
+
else
|
109
|
+
# No retry logic, just execute once
|
110
|
+
execute_chat_with_strategy(messages, signature_class, initial_strategy, &block)
|
111
|
+
end
|
112
|
+
end
|
113
|
+
|
114
|
+
def execute_chat_with_strategy(messages, signature_class, strategy, &block)
|
115
|
+
# Prepare request with strategy-specific modifications
|
116
|
+
request_params = {}
|
117
|
+
strategy.prepare_request(messages.dup, request_params)
|
118
|
+
|
119
|
+
# Make the request
|
120
|
+
response = if request_params.any?
|
121
|
+
# Pass additional parameters if strategy added them
|
122
|
+
adapter.chat(messages: messages, signature: signature_class, **request_params, &block)
|
123
|
+
else
|
124
|
+
adapter.chat(messages: messages, signature: signature_class, &block)
|
125
|
+
end
|
126
|
+
|
127
|
+
# Let strategy handle JSON extraction if needed
|
128
|
+
if signature_class && response.content
|
129
|
+
extracted_json = strategy.extract_json(response)
|
130
|
+
if extracted_json && extracted_json != response.content
|
131
|
+
# Create a new response with extracted JSON
|
132
|
+
response = Response.new(
|
133
|
+
content: extracted_json,
|
134
|
+
usage: response.usage,
|
135
|
+
metadata: response.metadata
|
136
|
+
)
|
137
|
+
end
|
138
|
+
end
|
139
|
+
|
140
|
+
response
|
141
|
+
end
|
142
|
+
|
92
143
|
# Determines if LM-level events should be emitted using smart consolidation
|
93
144
|
def should_emit_lm_events?
|
94
145
|
# Emit LM events only if we're not in a nested context (smart consolidation)
|
@@ -139,18 +190,6 @@ module DSPy
|
|
139
190
|
# Try to parse the response as JSON
|
140
191
|
content = response.content
|
141
192
|
|
142
|
-
# Let adapters handle their own extraction logic if available
|
143
|
-
if adapter && adapter.respond_to?(:extract_json_from_response, true)
|
144
|
-
content = adapter.send(:extract_json_from_response, content)
|
145
|
-
else
|
146
|
-
# Fallback: Extract JSON if it's in a code block (legacy behavior)
|
147
|
-
if content.include?('```json')
|
148
|
-
content = content.split('```json').last.split('```').first.strip
|
149
|
-
elsif content.include?('```')
|
150
|
-
content = content.split('```').last.split('```').first.strip
|
151
|
-
end
|
152
|
-
end
|
153
|
-
|
154
193
|
begin
|
155
194
|
json_payload = JSON.parse(content)
|
156
195
|
|
@@ -161,7 +200,6 @@ module DSPy
|
|
161
200
|
# Enhanced error message with debugging information
|
162
201
|
error_details = {
|
163
202
|
original_content: response.content,
|
164
|
-
extracted_content: content,
|
165
203
|
provider: provider,
|
166
204
|
model: model
|
167
205
|
}
|
data/lib/dspy/version.rb
CHANGED
data/lib/dspy.rb
CHANGED
@@ -21,6 +21,19 @@ module DSPy
|
|
21
21
|
setting :lm
|
22
22
|
setting :logger, default: Dry.Logger(:dspy, formatter: :string)
|
23
23
|
|
24
|
+
# Structured output configuration for LLM providers
|
25
|
+
setting :structured_outputs do
|
26
|
+
setting :openai, default: false
|
27
|
+
setting :anthropic, default: false # Reserved for future use
|
28
|
+
setting :strategy, default: nil # Can be 'openai_structured_output', 'anthropic_extraction', 'enhanced_prompting', or nil for auto
|
29
|
+
setting :retry_enabled, default: true
|
30
|
+
setting :max_retries, default: 3
|
31
|
+
setting :fallback_enabled, default: true
|
32
|
+
end
|
33
|
+
|
34
|
+
# Test mode disables sleeps in retry logic
|
35
|
+
setting :test_mode, default: false
|
36
|
+
|
24
37
|
# Nested instrumentation configuration using proper dry-configurable syntax
|
25
38
|
setting :instrumentation do
|
26
39
|
# Core settings
|
metadata
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: dspy
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.
|
4
|
+
version: 0.8.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Vicente Reig Rincón de Arellano
|
@@ -71,14 +71,14 @@ dependencies:
|
|
71
71
|
requirements:
|
72
72
|
- - "~>"
|
73
73
|
- !ruby/object:Gem::Version
|
74
|
-
version: 0.
|
74
|
+
version: 0.13.0
|
75
75
|
type: :runtime
|
76
76
|
prerelease: false
|
77
77
|
version_requirements: !ruby/object:Gem::Requirement
|
78
78
|
requirements:
|
79
79
|
- - "~>"
|
80
80
|
- !ruby/object:Gem::Version
|
81
|
-
version: 0.
|
81
|
+
version: 0.13.0
|
82
82
|
- !ruby/object:Gem::Dependency
|
83
83
|
name: anthropic
|
84
84
|
requirement: !ruby/object:Gem::Requirement
|
@@ -171,9 +171,17 @@ files:
|
|
171
171
|
- lib/dspy/lm/adapter.rb
|
172
172
|
- lib/dspy/lm/adapter_factory.rb
|
173
173
|
- lib/dspy/lm/adapters/anthropic_adapter.rb
|
174
|
+
- lib/dspy/lm/adapters/openai/schema_converter.rb
|
174
175
|
- lib/dspy/lm/adapters/openai_adapter.rb
|
176
|
+
- lib/dspy/lm/cache_manager.rb
|
175
177
|
- lib/dspy/lm/errors.rb
|
176
178
|
- lib/dspy/lm/response.rb
|
179
|
+
- lib/dspy/lm/retry_handler.rb
|
180
|
+
- lib/dspy/lm/strategies/anthropic_extraction_strategy.rb
|
181
|
+
- lib/dspy/lm/strategies/base_strategy.rb
|
182
|
+
- lib/dspy/lm/strategies/enhanced_prompting_strategy.rb
|
183
|
+
- lib/dspy/lm/strategies/openai_structured_output_strategy.rb
|
184
|
+
- lib/dspy/lm/strategy_selector.rb
|
177
185
|
- lib/dspy/memory.rb
|
178
186
|
- lib/dspy/memory/embedding_engine.rb
|
179
187
|
- lib/dspy/memory/in_memory_store.rb
|