dspy 0.34.3 → 0.34.4
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 +8 -16
- data/lib/dspy/context.rb +53 -20
- data/lib/dspy/lm/json_strategy.rb +28 -102
- data/lib/dspy/lm/response.rb +1 -1
- data/lib/dspy/lm/usage.rb +31 -6
- data/lib/dspy/lm.rb +13 -2
- data/lib/dspy/mixins/type_coercion.rb +76 -14
- data/lib/dspy/module.rb +133 -6
- data/lib/dspy/predict.rb +1 -1
- data/lib/dspy/prediction.rb +10 -1
- data/lib/dspy/prompt.rb +2 -46
- data/lib/dspy/re_act.rb +159 -34
- data/lib/dspy/signature.rb +4 -5
- data/lib/dspy/structured_outputs_prompt.rb +1 -1
- data/lib/dspy/utils/serialization.rb +2 -6
- data/lib/dspy/version.rb +1 -1
- data/lib/dspy.rb +49 -1
- metadata +7 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 5d5296e0130d0550156345659e6703451bd6ca6fb9ffbac33d8584ee203f2a84
|
|
4
|
+
data.tar.gz: 339772eb768a2babbb8b700b868fc772183473b7cf4f8a6867e55945f75a3655
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 827360cba1ad8d03373d40d9b1b9ce3acf966e896261b918537150568cf4635904e591c238cdea7d8e8bf45980851ce3c581f709639215c8d7e4c7e1ea78dc08
|
|
7
|
+
data.tar.gz: e8d91df3e7204ac0d0db830c5839a4cbdd9683a00be2a77dec38de156052f0a0b3956e25c8b7026fcce23880e9806fc9af023fc2a4fe830926f288f82474a85b
|
data/README.md
CHANGED
|
@@ -137,26 +137,18 @@ result.answer # => "60 km/h"
|
|
|
137
137
|
Build agents that use tools to accomplish tasks:
|
|
138
138
|
|
|
139
139
|
```ruby
|
|
140
|
-
class SearchTool < DSPy::Tools::
|
|
140
|
+
class SearchTool < DSPy::Tools::Base
|
|
141
141
|
tool_name "search"
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
input do
|
|
145
|
-
const :query, String
|
|
146
|
-
end
|
|
147
|
-
|
|
148
|
-
output do
|
|
149
|
-
const :results, T::Array[String]
|
|
150
|
-
end
|
|
142
|
+
tool_description "Search for information"
|
|
151
143
|
|
|
144
|
+
sig { params(query: String).returns(String) }
|
|
152
145
|
def call(query:)
|
|
153
146
|
# Your search implementation
|
|
154
|
-
|
|
147
|
+
"Result 1, Result 2"
|
|
155
148
|
end
|
|
156
149
|
end
|
|
157
150
|
|
|
158
|
-
|
|
159
|
-
agent = DSPy::ReAct.new(signature: ResearchTask, tools: toolset, max_iterations: 5)
|
|
151
|
+
agent = DSPy::ReAct.new(ResearchTask, tools: [SearchTool.new], max_iterations: 5)
|
|
160
152
|
result = agent.call(question: "What's the latest on Ruby 3.4?")
|
|
161
153
|
```
|
|
162
154
|
|
|
@@ -185,8 +177,8 @@ result = agent.call(question: "What's the latest on Ruby 3.4?")
|
|
|
185
177
|
A [Claude Skill](https://github.com/vicentereig/dspy-rb-skill) is available to help you build DSPy.rb applications:
|
|
186
178
|
|
|
187
179
|
```bash
|
|
188
|
-
# Claude Code
|
|
189
|
-
|
|
180
|
+
# Claude Code — install from the vicentereig/engineering marketplace
|
|
181
|
+
claude install-skill vicentereig/engineering --skill dspy-rb
|
|
190
182
|
```
|
|
191
183
|
|
|
192
184
|
For Claude.ai Pro/Max, download the [skill ZIP](https://github.com/vicentereig/dspy-rb-skill/archive/refs/heads/main.zip) and upload via Settings > Skills.
|
|
@@ -201,7 +193,7 @@ The [examples/](examples/) directory has runnable code for common patterns:
|
|
|
201
193
|
- Prompt optimization
|
|
202
194
|
|
|
203
195
|
```bash
|
|
204
|
-
bundle exec ruby examples/
|
|
196
|
+
bundle exec ruby examples/basic_search_agent.rb
|
|
205
197
|
```
|
|
206
198
|
|
|
207
199
|
## Optional Gems
|
data/lib/dspy/context.rb
CHANGED
|
@@ -74,8 +74,9 @@ module DSPy
|
|
|
74
74
|
# Prepare attributes and add trace name for root spans
|
|
75
75
|
span_attributes = sanitized_attributes.transform_keys(&:to_s).reject { |k, v| v.nil? }
|
|
76
76
|
|
|
77
|
-
# Set trace name if this is likely a root span (no parent in our stack)
|
|
78
|
-
|
|
77
|
+
# Set trace name if this is likely a root span (no parent in our stack),
|
|
78
|
+
# unless callers already specified one explicitly.
|
|
79
|
+
if current[:span_stack].length == 1 && !span_attributes.key?('langfuse.trace.name')
|
|
79
80
|
span_attributes['langfuse.trace.name'] = operation
|
|
80
81
|
end
|
|
81
82
|
|
|
@@ -84,6 +85,12 @@ module DSPy
|
|
|
84
85
|
|
|
85
86
|
# Get parent OpenTelemetry span for proper context propagation
|
|
86
87
|
parent_otel_span = current[:otel_span_stack].last
|
|
88
|
+
if !parent_otel_span && defined?(OpenTelemetry::Trace)
|
|
89
|
+
current_span = OpenTelemetry::Trace.current_span
|
|
90
|
+
if current_span && current_span != OpenTelemetry::Trace::Span::INVALID
|
|
91
|
+
parent_otel_span = current_span
|
|
92
|
+
end
|
|
93
|
+
end
|
|
87
94
|
|
|
88
95
|
# Create span with proper parent context
|
|
89
96
|
if parent_otel_span
|
|
@@ -96,20 +103,18 @@ module DSPy
|
|
|
96
103
|
) do |span|
|
|
97
104
|
# Add to our OpenTelemetry span stack
|
|
98
105
|
current[:otel_span_stack].push(span)
|
|
106
|
+
succeeded = false
|
|
99
107
|
|
|
100
108
|
begin
|
|
101
109
|
result = yield(span)
|
|
102
|
-
|
|
103
|
-
# Add explicit timing information to help Langfuse
|
|
104
|
-
if span
|
|
105
|
-
duration_ms = ((Time.now - otel_start_time) * 1000).round(3)
|
|
106
|
-
span.set_attribute('duration.ms', duration_ms)
|
|
107
|
-
span.set_attribute('langfuse.observation.startTime', otel_start_time.iso8601(3))
|
|
108
|
-
span.set_attribute('langfuse.observation.endTime', Time.now.iso8601(3))
|
|
109
|
-
end
|
|
110
|
-
|
|
110
|
+
succeeded = true
|
|
111
111
|
result
|
|
112
|
+
rescue StandardError => e
|
|
113
|
+
set_span_error_attributes(span, e)
|
|
114
|
+
raise
|
|
112
115
|
ensure
|
|
116
|
+
set_span_status_attribute(span, succeeded)
|
|
117
|
+
set_span_timing_attributes(span, otel_start_time)
|
|
113
118
|
# Remove from our OpenTelemetry span stack
|
|
114
119
|
current[:otel_span_stack].pop
|
|
115
120
|
end
|
|
@@ -124,20 +129,18 @@ module DSPy
|
|
|
124
129
|
) do |span|
|
|
125
130
|
# Add to our OpenTelemetry span stack
|
|
126
131
|
current[:otel_span_stack].push(span)
|
|
132
|
+
succeeded = false
|
|
127
133
|
|
|
128
134
|
begin
|
|
129
135
|
result = yield(span)
|
|
130
|
-
|
|
131
|
-
# Add explicit timing information to help Langfuse
|
|
132
|
-
if span
|
|
133
|
-
duration_ms = ((Time.now - otel_start_time) * 1000).round(3)
|
|
134
|
-
span.set_attribute('duration.ms', duration_ms)
|
|
135
|
-
span.set_attribute('langfuse.observation.startTime', otel_start_time.iso8601(3))
|
|
136
|
-
span.set_attribute('langfuse.observation.endTime', Time.now.iso8601(3))
|
|
137
|
-
end
|
|
138
|
-
|
|
136
|
+
succeeded = true
|
|
139
137
|
result
|
|
138
|
+
rescue StandardError => e
|
|
139
|
+
set_span_error_attributes(span, e)
|
|
140
|
+
raise
|
|
140
141
|
ensure
|
|
142
|
+
set_span_status_attribute(span, succeeded)
|
|
143
|
+
set_span_timing_attributes(span, otel_start_time)
|
|
141
144
|
# Remove from our OpenTelemetry span stack
|
|
142
145
|
current[:otel_span_stack].pop
|
|
143
146
|
end
|
|
@@ -296,6 +299,36 @@ module DSPy
|
|
|
296
299
|
label: explicit_label || (module_instance.respond_to?(:module_scope_label) ? module_instance.module_scope_label : nil)
|
|
297
300
|
}
|
|
298
301
|
end
|
|
302
|
+
|
|
303
|
+
def set_span_timing_attributes(span, otel_start_time)
|
|
304
|
+
return unless span
|
|
305
|
+
|
|
306
|
+
now = Time.now
|
|
307
|
+
duration_ms = ((now - otel_start_time) * 1000).round(3)
|
|
308
|
+
span.set_attribute('duration.ms', duration_ms)
|
|
309
|
+
span.set_attribute('langfuse.observation.startTime', otel_start_time.iso8601(3))
|
|
310
|
+
span.set_attribute('langfuse.observation.endTime', now.iso8601(3))
|
|
311
|
+
rescue StandardError
|
|
312
|
+
nil
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
def set_span_error_attributes(span, error)
|
|
316
|
+
return unless span
|
|
317
|
+
|
|
318
|
+
span.set_attribute('error', true)
|
|
319
|
+
span.set_attribute('error.type', error.class.name)
|
|
320
|
+
span.set_attribute('error.message', error.message.to_s[0, 2000]) if error.message
|
|
321
|
+
rescue StandardError
|
|
322
|
+
nil
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
def set_span_status_attribute(span, succeeded)
|
|
326
|
+
return unless span
|
|
327
|
+
|
|
328
|
+
span.set_attribute('dspy.status', succeeded ? 'completed' : 'error')
|
|
329
|
+
rescue StandardError
|
|
330
|
+
nil
|
|
331
|
+
end
|
|
299
332
|
end
|
|
300
333
|
end
|
|
301
334
|
end
|
|
@@ -38,17 +38,8 @@ module DSPy
|
|
|
38
38
|
# OpenAI/Ollama: try to extract JSON from various formats
|
|
39
39
|
extract_json_from_content(response.content)
|
|
40
40
|
elsif adapter_class_name.include?('AnthropicAdapter')
|
|
41
|
-
# Anthropic:
|
|
42
|
-
|
|
43
|
-
structured_outputs_enabled = true if structured_outputs_enabled.nil? # Default to true
|
|
44
|
-
|
|
45
|
-
if structured_outputs_enabled
|
|
46
|
-
extracted = extract_anthropic_tool_json(response)
|
|
47
|
-
extracted || extract_json_from_content(response.content)
|
|
48
|
-
else
|
|
49
|
-
# Skip tool extraction, use enhanced prompting extraction
|
|
50
|
-
extract_json_from_content(response.content)
|
|
51
|
-
end
|
|
41
|
+
# Anthropic: Beta API returns JSON in content, same as OpenAI/Gemini
|
|
42
|
+
extract_json_from_content(response.content)
|
|
52
43
|
elsif adapter_class_name.include?('GeminiAdapter')
|
|
53
44
|
# Gemini: try to extract JSON from various formats
|
|
54
45
|
extract_json_from_content(response.content)
|
|
@@ -90,25 +81,30 @@ module DSPy
|
|
|
90
81
|
# Anthropic preparation
|
|
91
82
|
sig { params(messages: T::Array[T::Hash[Symbol, T.untyped]], request_params: T::Hash[Symbol, T.untyped]).void }
|
|
92
83
|
def prepare_anthropic_request(messages, request_params)
|
|
93
|
-
|
|
94
|
-
|
|
84
|
+
begin
|
|
85
|
+
require "dspy/anthropic/lm/schema_converter"
|
|
86
|
+
rescue LoadError
|
|
87
|
+
msg = <<~MSG
|
|
88
|
+
Anthropic adapter is optional; structured output helpers will be unavailable until the gem is installed.
|
|
89
|
+
Add `gem 'dspy-anthropic'` to your Gemfile and run `bundle install`.
|
|
90
|
+
MSG
|
|
91
|
+
raise DSPy::LM::MissingAdapterError, msg
|
|
92
|
+
end
|
|
95
93
|
|
|
96
|
-
#
|
|
94
|
+
# Only use Beta API structured outputs if enabled (default: true)
|
|
95
|
+
structured_outputs_enabled = adapter.instance_variable_get(:@structured_outputs_enabled)
|
|
97
96
|
structured_outputs_enabled = true if structured_outputs_enabled.nil?
|
|
98
97
|
|
|
99
98
|
return unless structured_outputs_enabled
|
|
100
99
|
|
|
101
|
-
#
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
# Add tool definition
|
|
105
|
-
request_params[:tools] = [tool_schema]
|
|
100
|
+
# Use Anthropic Beta API structured outputs
|
|
101
|
+
schema = DSPy::Anthropic::LM::SchemaConverter.to_beta_format(signature_class)
|
|
106
102
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
103
|
+
request_params[:output_format] = ::Anthropic::Models::Beta::BetaJSONOutputFormat.new(
|
|
104
|
+
type: :json_schema,
|
|
105
|
+
schema: schema
|
|
106
|
+
)
|
|
107
|
+
request_params[:betas] = ["structured-outputs-2025-11-13"]
|
|
112
108
|
end
|
|
113
109
|
|
|
114
110
|
# Gemini preparation
|
|
@@ -135,89 +131,19 @@ module DSPy
|
|
|
135
131
|
end
|
|
136
132
|
end
|
|
137
133
|
|
|
138
|
-
# Convert signature to Anthropic tool schema
|
|
139
|
-
# Uses strict: true for constrained decoding (Anthropic structured outputs)
|
|
140
|
-
# Anthropic strict mode requires ALL properties in required at every level.
|
|
141
|
-
sig { returns(T::Hash[Symbol, T.untyped]) }
|
|
142
|
-
def convert_to_anthropic_tool_schema
|
|
143
|
-
output_fields = signature_class.output_field_descriptors
|
|
144
|
-
|
|
145
|
-
schema = {
|
|
146
|
-
name: "json_output",
|
|
147
|
-
description: "Output the result in the required JSON format",
|
|
148
|
-
strict: true,
|
|
149
|
-
input_schema: {
|
|
150
|
-
type: "object",
|
|
151
|
-
properties: build_properties_from_fields(output_fields),
|
|
152
|
-
required: build_required_from_fields(output_fields),
|
|
153
|
-
additionalProperties: false
|
|
154
|
-
}
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
# Anthropic strict mode: ALL properties must be in required at every level.
|
|
158
|
-
# Non-required properties get auto-wrapped in null unions by the grammar compiler,
|
|
159
|
-
# which counts against the 16-union-parameter limit.
|
|
160
|
-
enforce_all_required(schema[:input_schema])
|
|
161
|
-
|
|
162
|
-
schema
|
|
163
|
-
end
|
|
164
|
-
|
|
165
|
-
# Build required field list, excluding fields that have defaults
|
|
166
|
-
sig { params(fields: T::Hash[Symbol, T.untyped]).returns(T::Array[String]) }
|
|
167
|
-
def build_required_from_fields(fields)
|
|
168
|
-
fields.reject { |_name, descriptor| descriptor.has_default }.keys.map(&:to_s)
|
|
169
|
-
end
|
|
170
|
-
|
|
171
|
-
# Recursively enforce that all properties are in required and
|
|
172
|
-
# additionalProperties is false, as required by Anthropic strict mode.
|
|
173
|
-
sig { params(schema: T::Hash[Symbol, T.untyped]).void }
|
|
174
|
-
def enforce_all_required(schema)
|
|
175
|
-
return unless schema.is_a?(Hash)
|
|
176
|
-
|
|
177
|
-
if schema[:type] == "object" && schema[:properties]
|
|
178
|
-
schema[:required] = schema[:properties].keys.map(&:to_s)
|
|
179
|
-
schema[:additionalProperties] = false
|
|
180
|
-
schema[:properties].each_value { |v| enforce_all_required(v) }
|
|
181
|
-
elsif schema[:type] == "array" && schema[:items]
|
|
182
|
-
enforce_all_required(schema[:items])
|
|
183
|
-
elsif schema[:type].is_a?(Array)
|
|
184
|
-
# type: ["array", "null"] — check items if present
|
|
185
|
-
enforce_all_required(schema[:items]) if schema[:items]
|
|
186
|
-
end
|
|
187
|
-
end
|
|
188
|
-
|
|
189
|
-
# Build JSON schema properties from output fields
|
|
190
|
-
sig { params(fields: T::Hash[Symbol, T.untyped]).returns(T::Hash[String, T.untyped]) }
|
|
191
|
-
def build_properties_from_fields(fields)
|
|
192
|
-
properties = {}
|
|
193
|
-
fields.each do |field_name, descriptor|
|
|
194
|
-
properties[field_name.to_s] = DSPy::TypeSystem::SorbetJsonSchema.type_to_json_schema(descriptor.type)
|
|
195
|
-
end
|
|
196
|
-
properties
|
|
197
|
-
end
|
|
198
|
-
|
|
199
|
-
# Extract JSON from Anthropic tool use response
|
|
200
|
-
sig { params(response: DSPy::LM::Response).returns(T.nilable(String)) }
|
|
201
|
-
def extract_anthropic_tool_json(response)
|
|
202
|
-
# Check for tool calls in metadata
|
|
203
|
-
if response.metadata.respond_to?(:tool_calls) && response.metadata.tool_calls
|
|
204
|
-
tool_calls = response.metadata.tool_calls
|
|
205
|
-
if tool_calls.is_a?(Array) && !tool_calls.empty?
|
|
206
|
-
first_call = tool_calls.first
|
|
207
|
-
if first_call[:name] == "json_output" && first_call[:input]
|
|
208
|
-
return JSON.generate(first_call[:input])
|
|
209
|
-
end
|
|
210
|
-
end
|
|
211
|
-
end
|
|
212
|
-
|
|
213
|
-
nil
|
|
214
|
-
end
|
|
215
|
-
|
|
216
134
|
# Extract JSON from content that may contain markdown or plain JSON
|
|
217
135
|
sig { params(content: String).returns(String) }
|
|
218
136
|
def extract_json_from_content(content)
|
|
219
137
|
return content if content.nil? || content.empty?
|
|
220
138
|
|
|
139
|
+
# Fix Anthropic Beta API bug with optional fields producing invalid JSON
|
|
140
|
+
# When some output fields are optional and not returned, Anthropic's structured outputs
|
|
141
|
+
# can produce trailing comma+brace: {"field1": {...},} instead of {"field1": {...}}
|
|
142
|
+
# This workaround removes the invalid trailing syntax before JSON parsing
|
|
143
|
+
if content =~ /,\s*\}\s*$/
|
|
144
|
+
content = content.sub(/,(\s*\}\s*)$/, '\1')
|
|
145
|
+
end
|
|
146
|
+
|
|
221
147
|
# Try 1: Check for ```json code block (with or without preceding text)
|
|
222
148
|
if content.include?('```json')
|
|
223
149
|
json_match = content.match(/```json\s*\n(.*?)\n```/m)
|
data/lib/dspy/lm/response.rb
CHANGED
|
@@ -118,7 +118,7 @@ module DSPy
|
|
|
118
118
|
extend T::Sig
|
|
119
119
|
|
|
120
120
|
const :content, String
|
|
121
|
-
const :usage, T.nilable(T.any(Usage, OpenAIUsage)), default: nil
|
|
121
|
+
const :usage, T.nilable(T.any(Usage, OpenAIUsage, AnthropicUsage)), default: nil
|
|
122
122
|
const :metadata, T.any(ResponseMetadata, OpenAIResponseMetadata, AnthropicResponseMetadata, GeminiResponseMetadata, T::Hash[Symbol, T.untyped])
|
|
123
123
|
|
|
124
124
|
sig { returns(String) }
|
data/lib/dspy/lm/usage.rb
CHANGED
|
@@ -45,11 +45,34 @@ module DSPy
|
|
|
45
45
|
end
|
|
46
46
|
end
|
|
47
47
|
|
|
48
|
+
# Anthropic-specific usage information with cache token fields
|
|
49
|
+
class AnthropicUsage < T::Struct
|
|
50
|
+
extend T::Sig
|
|
51
|
+
|
|
52
|
+
const :input_tokens, Integer
|
|
53
|
+
const :output_tokens, Integer
|
|
54
|
+
const :total_tokens, Integer
|
|
55
|
+
const :cache_creation_input_tokens, T.nilable(Integer), default: nil
|
|
56
|
+
const :cache_read_input_tokens, T.nilable(Integer), default: nil
|
|
57
|
+
|
|
58
|
+
sig { returns(Hash) }
|
|
59
|
+
def to_h
|
|
60
|
+
base = {
|
|
61
|
+
input_tokens: input_tokens,
|
|
62
|
+
output_tokens: output_tokens,
|
|
63
|
+
total_tokens: total_tokens
|
|
64
|
+
}
|
|
65
|
+
base[:cache_creation_input_tokens] = cache_creation_input_tokens unless cache_creation_input_tokens.nil?
|
|
66
|
+
base[:cache_read_input_tokens] = cache_read_input_tokens unless cache_read_input_tokens.nil?
|
|
67
|
+
base
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
48
71
|
# Factory for creating appropriate usage objects
|
|
49
72
|
module UsageFactory
|
|
50
73
|
extend T::Sig
|
|
51
74
|
|
|
52
|
-
sig { params(provider: String, usage_data: T.untyped).returns(T.nilable(T.any(Usage, OpenAIUsage))) }
|
|
75
|
+
sig { params(provider: String, usage_data: T.untyped).returns(T.nilable(T.any(Usage, OpenAIUsage, AnthropicUsage))) }
|
|
53
76
|
def self.create(provider, usage_data)
|
|
54
77
|
return nil if usage_data.nil?
|
|
55
78
|
|
|
@@ -121,17 +144,19 @@ module DSPy
|
|
|
121
144
|
nil
|
|
122
145
|
end
|
|
123
146
|
|
|
124
|
-
sig { params(data: T::Hash[Symbol, T.untyped]).returns(T.nilable(
|
|
147
|
+
sig { params(data: T::Hash[Symbol, T.untyped]).returns(T.nilable(AnthropicUsage)) }
|
|
125
148
|
def self.create_anthropic_usage(data)
|
|
126
149
|
# Anthropic uses input_tokens/output_tokens
|
|
127
150
|
input_tokens = data[:input_tokens] || 0
|
|
128
151
|
output_tokens = data[:output_tokens] || 0
|
|
129
152
|
total_tokens = data[:total_tokens] || (input_tokens + output_tokens)
|
|
130
|
-
|
|
131
|
-
|
|
153
|
+
|
|
154
|
+
AnthropicUsage.new(
|
|
132
155
|
input_tokens: input_tokens,
|
|
133
156
|
output_tokens: output_tokens,
|
|
134
|
-
total_tokens: total_tokens
|
|
157
|
+
total_tokens: total_tokens,
|
|
158
|
+
cache_creation_input_tokens: data[:cache_creation_input_tokens],
|
|
159
|
+
cache_read_input_tokens: data[:cache_read_input_tokens]
|
|
135
160
|
)
|
|
136
161
|
rescue StandardError => e
|
|
137
162
|
DSPy.logger.debug("Failed to create Anthropic usage: #{e.message}")
|
|
@@ -173,4 +198,4 @@ module DSPy
|
|
|
173
198
|
end
|
|
174
199
|
end
|
|
175
200
|
end
|
|
176
|
-
end
|
|
201
|
+
end
|
data/lib/dspy/lm.rb
CHANGED
|
@@ -305,6 +305,12 @@ module DSPy
|
|
|
305
305
|
span.set_attribute('gen_ai.usage.prompt_tokens', usage.input_tokens) if usage.input_tokens
|
|
306
306
|
span.set_attribute('gen_ai.usage.completion_tokens', usage.output_tokens) if usage.output_tokens
|
|
307
307
|
span.set_attribute('gen_ai.usage.total_tokens', usage.total_tokens) if usage.total_tokens
|
|
308
|
+
if usage.respond_to?(:cache_creation_input_tokens) && !usage.cache_creation_input_tokens.nil?
|
|
309
|
+
span.set_attribute('gen_ai.usage.cache_creation_input_tokens', usage.cache_creation_input_tokens)
|
|
310
|
+
end
|
|
311
|
+
if usage.respond_to?(:cache_read_input_tokens) && !usage.cache_read_input_tokens.nil?
|
|
312
|
+
span.set_attribute('gen_ai.usage.cache_read_input_tokens', usage.cache_read_input_tokens)
|
|
313
|
+
end
|
|
308
314
|
end
|
|
309
315
|
end
|
|
310
316
|
|
|
@@ -356,11 +362,16 @@ module DSPy
|
|
|
356
362
|
|
|
357
363
|
# Handle Usage struct objects
|
|
358
364
|
if response.usage.respond_to?(:input_tokens)
|
|
359
|
-
|
|
365
|
+
result = {
|
|
360
366
|
input_tokens: response.usage.input_tokens,
|
|
361
367
|
output_tokens: response.usage.output_tokens,
|
|
362
368
|
total_tokens: response.usage.total_tokens
|
|
363
|
-
}
|
|
369
|
+
}
|
|
370
|
+
if response.usage.respond_to?(:cache_creation_input_tokens)
|
|
371
|
+
result[:cache_creation_input_tokens] = response.usage.cache_creation_input_tokens
|
|
372
|
+
result[:cache_read_input_tokens] = response.usage.cache_read_input_tokens
|
|
373
|
+
end
|
|
374
|
+
return result.compact
|
|
364
375
|
end
|
|
365
376
|
|
|
366
377
|
# Handle hash-based usage (for VCR compatibility)
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
# frozen_string_literal: true
|
|
3
3
|
|
|
4
4
|
require 'sorbet-runtime'
|
|
5
|
+
require 'yaml'
|
|
5
6
|
|
|
6
7
|
module DSPy
|
|
7
8
|
module Mixins
|
|
@@ -88,6 +89,15 @@ module DSPy
|
|
|
88
89
|
case prop_type
|
|
89
90
|
when ->(type) { union_type?(type) }
|
|
90
91
|
coerce_union_value(value, prop_type)
|
|
92
|
+
when ->(type) { nilable_type?(type) }
|
|
93
|
+
# Unwrap T.nilable(X) to coerce as X (nil already handled above)
|
|
94
|
+
non_nil_types = prop_type.types.reject { |t| t == T::Utils.coerce(NilClass) }
|
|
95
|
+
if non_nil_types.size == 1
|
|
96
|
+
coerce_value_to_type(value, non_nil_types.first)
|
|
97
|
+
else
|
|
98
|
+
# T.any(A, B, NilClass) — rebuild as T.any(A, B) and coerce as union
|
|
99
|
+
coerce_union_value(value, T::Types::Union.new(non_nil_types))
|
|
100
|
+
end
|
|
91
101
|
when ->(type) { array_type?(type) }
|
|
92
102
|
coerce_array_value(value, prop_type)
|
|
93
103
|
when ->(type) { hash_type?(type) }
|
|
@@ -161,15 +171,31 @@ module DSPy
|
|
|
161
171
|
# Checks if a type is a union type (T.any)
|
|
162
172
|
sig { params(type: T.untyped).returns(T::Boolean) }
|
|
163
173
|
def union_type?(type)
|
|
164
|
-
type.is_a?(T::Types::Union) && !
|
|
174
|
+
type.is_a?(T::Types::Union) && !nilable_type?(type)
|
|
165
175
|
end
|
|
166
176
|
|
|
167
177
|
# Checks if a type is nilable (contains NilClass)
|
|
168
178
|
sig { params(type: T.untyped).returns(T::Boolean) }
|
|
169
|
-
def
|
|
179
|
+
def nilable_type?(type)
|
|
170
180
|
type.is_a?(T::Types::Union) && type.types.any? { |t| t == T::Utils.coerce(NilClass) }
|
|
171
181
|
end
|
|
172
182
|
|
|
183
|
+
# Checks if a union type is a simple nilable struct (T.nilable(SomeStruct))
|
|
184
|
+
# Returns true only if the union has exactly 2 types: NilClass and a Struct
|
|
185
|
+
sig { params(union_type: T.untyped).returns(T::Boolean) }
|
|
186
|
+
def nilable_struct_union?(union_type)
|
|
187
|
+
return false unless union_type.is_a?(T::Types::Union)
|
|
188
|
+
|
|
189
|
+
types = union_type.types
|
|
190
|
+
return false unless types.size == 2
|
|
191
|
+
|
|
192
|
+
# One type must be NilClass, the other must be a struct
|
|
193
|
+
has_nil = types.any? { |t| t == T::Utils.coerce(NilClass) }
|
|
194
|
+
struct_type = types.find { |t| t != T::Utils.coerce(NilClass) && struct_type?(t) }
|
|
195
|
+
|
|
196
|
+
has_nil && !struct_type.nil?
|
|
197
|
+
end
|
|
198
|
+
|
|
173
199
|
# Checks if a type is a scalar (primitives that don't need special serialization)
|
|
174
200
|
sig { params(type_object: T.untyped).returns(T::Boolean) }
|
|
175
201
|
def scalar_type?(type_object)
|
|
@@ -283,9 +309,11 @@ module DSPy
|
|
|
283
309
|
# Coerces a hash value, converting keys and values as needed
|
|
284
310
|
sig { params(value: T.untyped, prop_type: T.untyped).returns(T.untyped) }
|
|
285
311
|
def coerce_hash_value(value, prop_type)
|
|
286
|
-
return value unless value.is_a?(Hash)
|
|
287
312
|
return value unless prop_type.is_a?(T::Types::TypedHash)
|
|
288
|
-
|
|
313
|
+
|
|
314
|
+
value = try_parse_string_to_hash(value)
|
|
315
|
+
return value unless value.is_a?(Hash)
|
|
316
|
+
|
|
289
317
|
key_type = prop_type.keys
|
|
290
318
|
value_type = prop_type.values
|
|
291
319
|
|
|
@@ -302,9 +330,41 @@ module DSPy
|
|
|
302
330
|
result.transform_values { |v| coerce_value_to_type(v, value_type) }
|
|
303
331
|
end
|
|
304
332
|
|
|
333
|
+
# Attempts to parse a string into a Hash.
|
|
334
|
+
# Returns the parsed Hash on success, or the original value otherwise.
|
|
335
|
+
sig { params(value: T.untyped).returns(T.untyped) }
|
|
336
|
+
def try_parse_string_to_hash(value)
|
|
337
|
+
return value unless value.is_a?(String)
|
|
338
|
+
|
|
339
|
+
parsed = begin
|
|
340
|
+
JSON.parse(value)
|
|
341
|
+
rescue JSON::ParserError
|
|
342
|
+
YAML.safe_load(value, permitted_classes: [Symbol, Date, Time])
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
parsed.is_a?(Hash) ? parsed : value
|
|
346
|
+
rescue Psych::SyntaxError
|
|
347
|
+
value
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
# Attempts to parse a JSON string into a Hash.
|
|
351
|
+
# Returns the parsed Hash on success, or the original value otherwise.
|
|
352
|
+
sig { params(value: T.untyped).returns(T.untyped) }
|
|
353
|
+
def try_parse_json_to_hash(value)
|
|
354
|
+
return value unless value.is_a?(String)
|
|
355
|
+
|
|
356
|
+
parsed = JSON.parse(value)
|
|
357
|
+
parsed.is_a?(Hash) ? parsed : value
|
|
358
|
+
rescue JSON::ParserError
|
|
359
|
+
value
|
|
360
|
+
end
|
|
361
|
+
|
|
305
362
|
# Coerces a struct value from a hash
|
|
306
363
|
sig { params(value: T.untyped, prop_type: T.untyped).returns(T.untyped) }
|
|
307
364
|
def coerce_struct_value(value, prop_type)
|
|
365
|
+
# Anthropic tool use may return struct fields as JSON strings
|
|
366
|
+
value = try_parse_json_to_hash(value)
|
|
367
|
+
|
|
308
368
|
return value unless value.is_a?(Hash)
|
|
309
369
|
|
|
310
370
|
struct_class = if prop_type.is_a?(Class)
|
|
@@ -347,7 +407,7 @@ module DSPy
|
|
|
347
407
|
next false unless prop_info
|
|
348
408
|
prop_type = prop_info[:type_object] || prop_info[:type]
|
|
349
409
|
has_default = prop_info.key?(:default) || prop_info[:fully_optional]
|
|
350
|
-
!
|
|
410
|
+
!nilable_type?(prop_type) && has_default
|
|
351
411
|
end
|
|
352
412
|
|
|
353
413
|
# Create the struct instance
|
|
@@ -363,18 +423,20 @@ module DSPy
|
|
|
363
423
|
def coerce_union_value(value, union_type)
|
|
364
424
|
# Anthropic tool use may return complex oneOf union fields as JSON strings
|
|
365
425
|
# instead of nested objects. Parse them back into Hashes for coercion.
|
|
366
|
-
|
|
367
|
-
begin
|
|
368
|
-
parsed = JSON.parse(value)
|
|
369
|
-
value = parsed if parsed.is_a?(Hash)
|
|
370
|
-
rescue JSON::ParserError
|
|
371
|
-
# Not JSON — fall through
|
|
372
|
-
end
|
|
373
|
-
end
|
|
426
|
+
value = try_parse_json_to_hash(value)
|
|
374
427
|
|
|
375
428
|
return value unless value.is_a?(Hash)
|
|
376
429
|
|
|
377
|
-
#
|
|
430
|
+
# Handle nilable struct unions (T.nilable(SomeStruct)) without _type discriminator
|
|
431
|
+
# LLMs don't provide _type for simple nilable structs, so we can directly coerce
|
|
432
|
+
if nilable_struct_union?(union_type)
|
|
433
|
+
struct_type = union_type.types.find { |t|
|
|
434
|
+
t != T::Utils.coerce(NilClass) && struct_type?(t)
|
|
435
|
+
}
|
|
436
|
+
return coerce_struct_value(value, struct_type) if struct_type
|
|
437
|
+
end
|
|
438
|
+
|
|
439
|
+
# Check for _type discriminator field (required for true multi-type unions)
|
|
378
440
|
type_name = value[:_type] || value["_type"]
|
|
379
441
|
return value unless type_name
|
|
380
442
|
|