dspy 0.11.0 → 0.13.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: de6126089636bd0d5fdaf9cd2bba791157186683a7984174bac980be8e32a483
4
- data.tar.gz: 1e78c20f7e1a37cf025158be0f4014ee2abe9ba95b84682361ff9ea544fecd2c
3
+ metadata.gz: d80d5b0166fe5a101e4918ffee13a70dec6ca67b493cf2e68dff1c18b2df36c1
4
+ data.tar.gz: 1687fe88d41c5d4627592ff5e98f87ca9f40186870386291b1ead091f51235da
5
5
  SHA512:
6
- metadata.gz: 7b3dbde7e5040dc1b0562142bdc560f9fc393bbb7cf20bef4d767541be079408dc1c6e196257f07193266713585bdfb21ac77bd08df3c6ab69607354393987c2
7
- data.tar.gz: 412cf071635f85bb3fb08f339a1dce23d4a9f00144104fc03fc25cc706d87c86c707221bc8a98f6847a7e157e2fa86de8bee4ee7c44d2bd1b9b0ab41934dd411
6
+ metadata.gz: 7bedebf2e58243bedcf8003d25b4f55789a7e4a611f9f1997a322e93db553fe117fde155415c5a607a65b49f61f2a640c7899a0fd4793bcdf9e8672602f54755
7
+ data.tar.gz: cdf39e605a7550e94334c5bad13ccc5af2a255b7039559b56b918e1ba3706e671c52960558459a0c96edab318c68bc9828b1eb03530b47daa896766a2cb7aa7d
data/README.md CHANGED
@@ -1,5 +1,8 @@
1
1
  # DSPy.rb
2
2
 
3
+ [![Gem Version](https://img.shields.io/gem/v/dspy)](https://rubygems.org/gems/dspy)
4
+ [![Total Downloads](https://img.shields.io/gem/dt/dspy)](https://rubygems.org/gems/dspy)
5
+
3
6
  **Build reliable LLM applications in Ruby using composable, type-safe modules.**
4
7
 
5
8
  DSPy.rb brings structured LLM programming to Ruby developers. Instead of wrestling with prompt strings and parsing responses, you define typed signatures and compose them into pipelines that just work.
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative '../lm/usage'
4
+
3
5
  module DSPy
4
6
  module Instrumentation
5
7
  # Utility for extracting token usage from different LM adapters
@@ -9,6 +11,18 @@ module DSPy
9
11
 
10
12
  # Extract actual token usage from API responses
11
13
  def extract_token_usage(response, provider)
14
+ return {} unless response&.usage
15
+
16
+ # Handle Usage struct
17
+ if response.usage.is_a?(DSPy::LM::Usage) || response.usage.is_a?(DSPy::LM::OpenAIUsage)
18
+ return {
19
+ input_tokens: response.usage.input_tokens,
20
+ output_tokens: response.usage.output_tokens,
21
+ total_tokens: response.usage.total_tokens
22
+ }
23
+ end
24
+
25
+ # Fallback to legacy hash handling
12
26
  case provider.to_s.downcase
13
27
  when 'openai'
14
28
  extract_openai_tokens(response)
@@ -27,11 +41,12 @@ module DSPy
27
41
  usage = response.usage
28
42
  return {} unless usage.is_a?(Hash)
29
43
 
44
+ # Handle both symbol and string keys for VCR compatibility
30
45
  {
31
46
  input_tokens: usage[:prompt_tokens] || usage['prompt_tokens'],
32
47
  output_tokens: usage[:completion_tokens] || usage['completion_tokens'],
33
48
  total_tokens: usage[:total_tokens] || usage['total_tokens']
34
- }
49
+ }.compact # Remove nil values
35
50
  end
36
51
 
37
52
  def extract_anthropic_tokens(response)
@@ -40,6 +55,7 @@ module DSPy
40
55
  usage = response.usage
41
56
  return {} unless usage.is_a?(Hash)
42
57
 
58
+ # Handle both symbol and string keys for VCR compatibility
43
59
  input_tokens = usage[:input_tokens] || usage['input_tokens'] || 0
44
60
  output_tokens = usage[:output_tokens] || usage['output_tokens'] || 0
45
61
 
@@ -47,7 +63,7 @@ module DSPy
47
63
  input_tokens: input_tokens,
48
64
  output_tokens: output_tokens,
49
65
  total_tokens: input_tokens + output_tokens
50
- }
66
+ }.compact # Remove nil values
51
67
  end
52
68
  end
53
69
  end
@@ -63,9 +63,12 @@ module DSPy
63
63
  content = response.content.first.text if response.content.is_a?(Array) && response.content.first
64
64
  usage = response.usage
65
65
 
66
+ # Convert usage data to typed struct
67
+ usage_struct = UsageFactory.create('anthropic', usage)
68
+
66
69
  Response.new(
67
70
  content: content,
68
- usage: usage.respond_to?(:to_h) ? usage.to_h : usage,
71
+ usage: usage_struct,
69
72
  metadata: {
70
73
  provider: 'anthropic',
71
74
  model: model,
@@ -52,9 +52,12 @@ module DSPy
52
52
  raise AdapterError, "OpenAI refused to generate output: #{message.refusal}"
53
53
  end
54
54
 
55
+ # Convert usage data to typed struct
56
+ usage_struct = UsageFactory.create('openai', usage)
57
+
55
58
  Response.new(
56
59
  content: content,
57
- usage: usage.respond_to?(:to_h) ? usage.to_h : usage,
60
+ usage: usage_struct,
58
61
  metadata: {
59
62
  provider: 'openai',
60
63
  model: model,
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DSPy
4
+ class LM
5
+ class MessageBuilder
6
+ attr_reader :messages
7
+
8
+ def initialize
9
+ @messages = []
10
+ end
11
+
12
+ def system(content)
13
+ @messages << { role: 'system', content: content.to_s }
14
+ self
15
+ end
16
+
17
+ def user(content)
18
+ @messages << { role: 'user', content: content.to_s }
19
+ self
20
+ end
21
+
22
+ def assistant(content)
23
+ @messages << { role: 'assistant', content: content.to_s }
24
+ self
25
+ end
26
+ end
27
+ end
28
+ end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative 'usage'
4
+
3
5
  module DSPy
4
6
  class LM
5
7
  # Normalized response format for all LM providers
@@ -0,0 +1,157 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'sorbet-runtime'
4
+
5
+ module DSPy
6
+ class LM
7
+ # Base class for token usage information
8
+ class Usage < T::Struct
9
+ extend T::Sig
10
+
11
+ const :input_tokens, Integer
12
+ const :output_tokens, Integer
13
+ const :total_tokens, Integer
14
+
15
+ sig { returns(Hash) }
16
+ def to_h
17
+ {
18
+ input_tokens: input_tokens,
19
+ output_tokens: output_tokens,
20
+ total_tokens: total_tokens
21
+ }
22
+ end
23
+ end
24
+
25
+ # OpenAI-specific usage information with additional fields
26
+ class OpenAIUsage < T::Struct
27
+ extend T::Sig
28
+
29
+ const :input_tokens, Integer
30
+ const :output_tokens, Integer
31
+ const :total_tokens, Integer
32
+ const :prompt_tokens_details, T.nilable(T::Hash[Symbol, Integer]), default: nil
33
+ const :completion_tokens_details, T.nilable(T::Hash[Symbol, Integer]), default: nil
34
+
35
+ sig { returns(Hash) }
36
+ def to_h
37
+ base = {
38
+ input_tokens: input_tokens,
39
+ output_tokens: output_tokens,
40
+ total_tokens: total_tokens
41
+ }
42
+ base[:prompt_tokens_details] = prompt_tokens_details if prompt_tokens_details
43
+ base[:completion_tokens_details] = completion_tokens_details if completion_tokens_details
44
+ base
45
+ end
46
+ end
47
+
48
+ # Factory for creating appropriate usage objects
49
+ module UsageFactory
50
+ extend T::Sig
51
+
52
+ sig { params(provider: String, usage_data: T.untyped).returns(T.nilable(T.any(Usage, OpenAIUsage))) }
53
+ def self.create(provider, usage_data)
54
+ return nil if usage_data.nil?
55
+
56
+ # If already a Usage struct, return as-is
57
+ return usage_data if usage_data.is_a?(Usage)
58
+
59
+ # Handle test doubles by converting to hash
60
+ if usage_data.respond_to?(:to_h)
61
+ usage_data = usage_data.to_h
62
+ end
63
+
64
+ # Convert hash to appropriate struct
65
+ return nil unless usage_data.is_a?(Hash)
66
+
67
+ # Normalize keys to symbols
68
+ normalized = usage_data.transform_keys(&:to_sym)
69
+
70
+ case provider.to_s.downcase
71
+ when 'openai'
72
+ create_openai_usage(normalized)
73
+ when 'anthropic'
74
+ create_anthropic_usage(normalized)
75
+ else
76
+ create_generic_usage(normalized)
77
+ end
78
+ end
79
+
80
+ private
81
+
82
+ sig { params(data: T::Hash[Symbol, T.untyped]).returns(T.nilable(OpenAIUsage)) }
83
+ def self.create_openai_usage(data)
84
+ # OpenAI uses prompt_tokens/completion_tokens
85
+ input_tokens = data[:prompt_tokens] || data[:input_tokens] || 0
86
+ output_tokens = data[:completion_tokens] || data[:output_tokens] || 0
87
+ total_tokens = data[:total_tokens] || (input_tokens + output_tokens)
88
+
89
+ # Convert prompt_tokens_details and completion_tokens_details to hashes if needed
90
+ prompt_details = convert_to_hash(data[:prompt_tokens_details])
91
+ completion_details = convert_to_hash(data[:completion_tokens_details])
92
+
93
+ OpenAIUsage.new(
94
+ input_tokens: input_tokens,
95
+ output_tokens: output_tokens,
96
+ total_tokens: total_tokens,
97
+ prompt_tokens_details: prompt_details,
98
+ completion_tokens_details: completion_details
99
+ )
100
+ rescue => e
101
+ DSPy.logger.debug("Failed to create OpenAI usage: #{e.message}")
102
+ nil
103
+ end
104
+
105
+ sig { params(value: T.untyped).returns(T.nilable(T::Hash[Symbol, Integer])) }
106
+ def self.convert_to_hash(value)
107
+ return nil if value.nil?
108
+ return value if value.is_a?(Hash) && value.keys.all? { |k| k.is_a?(Symbol) }
109
+
110
+ # Convert object to hash if it responds to to_h
111
+ if value.respond_to?(:to_h)
112
+ hash = value.to_h
113
+ # Ensure symbol keys and integer values
114
+ hash.transform_keys(&:to_sym).transform_values(&:to_i)
115
+ else
116
+ nil
117
+ end
118
+ rescue
119
+ nil
120
+ end
121
+
122
+ sig { params(data: T::Hash[Symbol, T.untyped]).returns(T.nilable(Usage)) }
123
+ def self.create_anthropic_usage(data)
124
+ # Anthropic uses input_tokens/output_tokens
125
+ input_tokens = data[:input_tokens] || 0
126
+ output_tokens = data[:output_tokens] || 0
127
+ total_tokens = data[:total_tokens] || (input_tokens + output_tokens)
128
+
129
+ Usage.new(
130
+ input_tokens: input_tokens,
131
+ output_tokens: output_tokens,
132
+ total_tokens: total_tokens
133
+ )
134
+ rescue => e
135
+ DSPy.logger.debug("Failed to create Anthropic usage: #{e.message}")
136
+ nil
137
+ end
138
+
139
+ sig { params(data: T::Hash[Symbol, T.untyped]).returns(T.nilable(Usage)) }
140
+ def self.create_generic_usage(data)
141
+ # Generic fallback
142
+ input_tokens = data[:input_tokens] || data[:prompt_tokens] || 0
143
+ output_tokens = data[:output_tokens] || data[:completion_tokens] || 0
144
+ total_tokens = data[:total_tokens] || (input_tokens + output_tokens)
145
+
146
+ Usage.new(
147
+ input_tokens: input_tokens,
148
+ output_tokens: output_tokens,
149
+ total_tokens: total_tokens
150
+ )
151
+ rescue => e
152
+ DSPy.logger.debug("Failed to create generic usage: #{e.message}")
153
+ nil
154
+ end
155
+ end
156
+ end
157
+ end
data/lib/dspy/lm.rb CHANGED
@@ -18,6 +18,9 @@ require_relative 'lm/adapters/anthropic_adapter'
18
18
  require_relative 'lm/strategy_selector'
19
19
  require_relative 'lm/retry_handler'
20
20
 
21
+ # Load message builder
22
+ require_relative 'lm/message_builder'
23
+
21
24
  module DSPy
22
25
  class LM
23
26
  attr_reader :model_id, :api_key, :model, :provider, :adapter
@@ -39,41 +42,13 @@ module DSPy
39
42
  # Build messages from inference module
40
43
  messages = build_messages(inference_module, input_values)
41
44
 
42
- # Calculate input size for monitoring
43
- input_text = messages.map { |m| m[:content] }.join(' ')
44
- input_size = input_text.length
45
-
46
- # Use smart consolidation: emit LM events only when not in nested context
47
- response = nil
48
- token_usage = {}
45
+ # Execute with instrumentation
46
+ response = instrument_lm_request(messages, signature_class.name) do
47
+ chat_with_strategy(messages, signature_class, &block)
48
+ end
49
49
 
50
+ # Instrument response parsing
50
51
  if should_emit_lm_events?
51
- # Emit all LM events when not in nested context
52
- response = Instrumentation.instrument('dspy.lm.request', {
53
- gen_ai_operation_name: 'chat',
54
- gen_ai_system: provider,
55
- gen_ai_request_model: model,
56
- signature_class: signature_class.name,
57
- provider: provider,
58
- adapter_class: adapter.class.name,
59
- input_size: input_size
60
- }) do
61
- chat_with_strategy(messages, signature_class, &block)
62
- end
63
-
64
- # Extract actual token usage from response (more accurate than estimation)
65
- token_usage = Instrumentation::TokenTracker.extract_token_usage(response, provider)
66
-
67
- # Emit token usage event if available
68
- if token_usage.any?
69
- Instrumentation.emit('dspy.lm.tokens', token_usage.merge({
70
- gen_ai_system: provider,
71
- gen_ai_request_model: model,
72
- signature_class: signature_class.name
73
- }))
74
- end
75
-
76
- # Instrument response parsing
77
52
  parsed_result = Instrumentation.instrument('dspy.lm.response.parsed', {
78
53
  signature_class: signature_class.name,
79
54
  provider: provider,
@@ -82,15 +57,33 @@ module DSPy
82
57
  parse_response(response, input_values, signature_class)
83
58
  end
84
59
  else
85
- # Consolidated mode: execute without nested instrumentation
86
- response = chat_with_strategy(messages, signature_class, &block)
87
- token_usage = Instrumentation::TokenTracker.extract_token_usage(response, provider)
88
60
  parsed_result = parse_response(response, input_values, signature_class)
89
61
  end
90
62
 
91
63
  parsed_result
92
64
  end
93
65
 
66
+ def raw_chat(messages = nil, &block)
67
+ # Support both array format and builder DSL
68
+ if block_given? && messages.nil?
69
+ # DSL mode - block is for building messages
70
+ builder = MessageBuilder.new
71
+ yield builder
72
+ messages = builder.messages
73
+ streaming_block = nil
74
+ else
75
+ # Array mode - block is for streaming
76
+ messages ||= []
77
+ streaming_block = block
78
+ end
79
+
80
+ # Validate messages format
81
+ validate_messages!(messages)
82
+
83
+ # Execute with instrumentation
84
+ execute_raw_chat(messages, &streaming_block)
85
+ end
86
+
94
87
  private
95
88
 
96
89
  def chat_with_strategy(messages, signature_class, &block)
@@ -208,5 +201,77 @@ module DSPy
208
201
  raise "Failed to parse LLM response as JSON: #{e.message}. Original content length: #{response.content&.length || 0} chars"
209
202
  end
210
203
  end
204
+
205
+ # Common instrumentation method for LM requests
206
+ def instrument_lm_request(messages, signature_class_name, &execution_block)
207
+ input_text = messages.map { |m| m[:content] }.join(' ')
208
+ input_size = input_text.length
209
+
210
+ response = nil
211
+
212
+ if should_emit_lm_events?
213
+ # Emit dspy.lm.request event
214
+ response = Instrumentation.instrument('dspy.lm.request', {
215
+ gen_ai_operation_name: 'chat',
216
+ gen_ai_system: provider,
217
+ gen_ai_request_model: model,
218
+ signature_class: signature_class_name,
219
+ provider: provider,
220
+ adapter_class: adapter.class.name,
221
+ input_size: input_size
222
+ }, &execution_block)
223
+
224
+ # Extract and emit token usage
225
+ emit_token_usage(response, signature_class_name)
226
+ else
227
+ # Consolidated mode: execute without instrumentation
228
+ response = execution_block.call
229
+ end
230
+
231
+ response
232
+ end
233
+
234
+ # Common method to emit token usage events
235
+ def emit_token_usage(response, signature_class_name)
236
+ token_usage = Instrumentation::TokenTracker.extract_token_usage(response, provider)
237
+
238
+ if token_usage.any?
239
+ Instrumentation.emit('dspy.lm.tokens', token_usage.merge({
240
+ gen_ai_system: provider,
241
+ gen_ai_request_model: model,
242
+ signature_class: signature_class_name
243
+ }))
244
+ end
245
+
246
+ token_usage
247
+ end
248
+
249
+ def validate_messages!(messages)
250
+ unless messages.is_a?(Array)
251
+ raise ArgumentError, "messages must be an array"
252
+ end
253
+
254
+ valid_roles = %w[system user assistant]
255
+
256
+ messages.each do |message|
257
+ unless message.is_a?(Hash) && message.key?(:role) && message.key?(:content)
258
+ raise ArgumentError, "Each message must have :role and :content"
259
+ end
260
+
261
+ unless valid_roles.include?(message[:role])
262
+ raise ArgumentError, "Invalid role: #{message[:role]}. Must be one of: #{valid_roles.join(', ')}"
263
+ end
264
+ end
265
+ end
266
+
267
+ def execute_raw_chat(messages, &streaming_block)
268
+ response = instrument_lm_request(messages, 'RawPrompt') do
269
+ # Direct adapter call, no strategies or JSON parsing
270
+ adapter.chat(messages: messages, signature: nil, &streaming_block)
271
+ end
272
+
273
+ # Return raw response content, not parsed JSON
274
+ response.content
275
+ end
211
276
  end
212
277
  end
data/lib/dspy/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module DSPy
4
- VERSION = "0.11.0"
4
+ VERSION = "0.13.0"
5
5
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: dspy
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.11.0
4
+ version: 0.13.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Vicente Reig Rincón de Arellano
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2025-07-21 00:00:00.000000000 Z
11
+ date: 2025-07-25 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: dry-configurable
@@ -177,6 +177,7 @@ files:
177
177
  - lib/dspy/lm/adapters/openai_adapter.rb
178
178
  - lib/dspy/lm/cache_manager.rb
179
179
  - lib/dspy/lm/errors.rb
180
+ - lib/dspy/lm/message_builder.rb
180
181
  - lib/dspy/lm/response.rb
181
182
  - lib/dspy/lm/retry_handler.rb
182
183
  - lib/dspy/lm/strategies/anthropic_extraction_strategy.rb
@@ -185,6 +186,7 @@ files:
185
186
  - lib/dspy/lm/strategies/openai_structured_output_strategy.rb
186
187
  - lib/dspy/lm/strategy_selector.rb
187
188
  - lib/dspy/lm/structured_output_strategy.rb
189
+ - lib/dspy/lm/usage.rb
188
190
  - lib/dspy/memory.rb
189
191
  - lib/dspy/memory/embedding_engine.rb
190
192
  - lib/dspy/memory/in_memory_store.rb