dspy-o11y-langfuse 1.0.1 → 1.1.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 +17 -5
- data/lib/dspy/o11y/langfuse/scores_exporter.rb +222 -0
- data/lib/dspy/o11y/langfuse/version.rb +1 -1
- data/lib/dspy/o11y/langfuse.rb +3 -0
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 681cd7bc2d72f935e3e86ba63e8f7846ff385cf98c485e49ed8c67065eb5e254
|
|
4
|
+
data.tar.gz: '03792ca1ab58794ab07be57da2ff73c1f522212114b0fec18db8cea674d74e22'
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 5ae475d8c8d502806ea6c7edb647dc8378b9a68d35ebe9972717a0aac821a5d769e30147b0796efa7d94ead8e836e6f2169bfa0bb767384a14ad1190fc9bf12f
|
|
7
|
+
data.tar.gz: 3d9d35c62ead40d58d87ccf4eca2e0a23c811a864ed3e44f0c8ba23bab3c3e50e3406a7888012925c7f74af014ab9299ce33bfa3f6b66549424ca63c6f817413
|
data/README.md
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
[](https://rubygems.org/gems/dspy)
|
|
4
4
|
[](https://rubygems.org/gems/dspy)
|
|
5
5
|
[](https://github.com/vicentereig/dspy.rb/actions/workflows/ruby.yml)
|
|
6
|
-
[](https://oss.vicente.services/dspy.rb/)
|
|
7
7
|
[](https://discord.gg/zWBhrMqn)
|
|
8
8
|
|
|
9
9
|
> [!NOTE]
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
|
|
17
17
|
**Build reliable LLM applications in idiomatic Ruby using composable, type-safe modules.**
|
|
18
18
|
|
|
19
|
-
DSPy.rb is the Ruby-first surgical port of Stanford's [DSPy
|
|
19
|
+
DSPy.rb is the Ruby-first surgical port of Stanford's [DSPy paradigm](https://github.com/stanfordnlp/dspy). It delivers structured LLM programming, prompt engineering, and context engineering in the language we love. Instead of wrestling with brittle prompt strings, you define typed signatures in idiomatic Ruby and compose workflows and agents that actually behave.
|
|
20
20
|
|
|
21
21
|
**Prompts are just functions.** Traditional prompting is like writing code with string concatenation: it works until it doesn't. DSPy.rb brings you the programming approach pioneered by [dspy.ai](https://dspy.ai/): define modular signatures and let the framework deal with the messy bits.
|
|
22
22
|
|
|
@@ -102,6 +102,7 @@ DSPy.rb ships multiple gems from this monorepo so you can opt into features with
|
|
|
102
102
|
| `dspy-openai` | Packages the OpenAI/OpenRouter/Ollama adapters plus the official SDK guardrails. Install whenever you call `openai/*`, `openrouter/*`, or `ollama/*`. [Adapter README](https://github.com/vicentereig/dspy.rb/blob/main/lib/dspy/openai/README.md) | **Stable** (v1.0.0) |
|
|
103
103
|
| `dspy-anthropic` | Claude adapters, streaming, and structured-output helpers behind the official `anthropic` SDK. [Adapter README](https://github.com/vicentereig/dspy.rb/blob/main/lib/dspy/anthropic/README.md) | **Stable** (v1.0.0) |
|
|
104
104
|
| `dspy-gemini` | Gemini adapters with multimodal + tool-call support via `gemini-ai`. [Adapter README](https://github.com/vicentereig/dspy.rb/blob/main/lib/dspy/gemini/README.md) | **Stable** (v1.0.0) |
|
|
105
|
+
| `dspy-ruby_llm` | Unified access to 12+ LLM providers (OpenAI, Anthropic, Gemini, Bedrock, Ollama, DeepSeek, etc.) via [RubyLLM](https://rubyllm.com). [Adapter README](https://github.com/vicentereig/dspy.rb/blob/main/lib/dspy/ruby_llm/README.md) | **Stable** (v0.1.0) |
|
|
105
106
|
| `dspy-code_act` | Think-Code-Observe agents that synthesize and execute Ruby safely. (Add the gem or set `DSPY_WITH_CODE_ACT=1` before requiring `dspy/code_act`.) | **Stable** (v1.0.0) |
|
|
106
107
|
| `dspy-datasets` | Dataset helpers plus Parquet/Polars tooling for richer evaluation corpora. (Toggle via `DSPY_WITH_DATASETS`.) | **Stable** (v1.0.0) |
|
|
107
108
|
| `dspy-evals` | High-throughput evaluation harness with metrics, callbacks, and regression fixtures. (Toggle via `DSPY_WITH_EVALS`.) | **Stable** (v1.0.0) |
|
|
@@ -247,13 +248,24 @@ DSPy.rb has gone from experimental to production-ready in three fast releases.
|
|
|
247
248
|
|
|
248
249
|
## Documentation
|
|
249
250
|
|
|
250
|
-
📖 **[Complete Documentation Website](https://
|
|
251
|
+
📖 **[Complete Documentation Website](https://oss.vicente.services/dspy.rb/)**
|
|
251
252
|
|
|
252
253
|
### LLM-Friendly Documentation
|
|
253
254
|
|
|
254
255
|
For LLMs and AI assistants working with DSPy.rb:
|
|
255
|
-
- **[llms.txt](https://
|
|
256
|
-
- **[llms-full.txt](https://
|
|
256
|
+
- **[llms.txt](https://oss.vicente.services/dspy.rb/llms.txt)** - Concise reference optimized for LLMs
|
|
257
|
+
- **[llms-full.txt](https://oss.vicente.services/dspy.rb/llms-full.txt)** - Comprehensive API documentation
|
|
258
|
+
|
|
259
|
+
### Claude Skill
|
|
260
|
+
|
|
261
|
+
A [Claude Skill](https://github.com/vicentereig/dspy-rb-skill) is available to help you build DSPy.rb applications with Claude Code or claude.ai.
|
|
262
|
+
|
|
263
|
+
**Claude Code:**
|
|
264
|
+
```bash
|
|
265
|
+
git clone https://github.com/vicentereig/dspy-rb-skill ~/.claude/skills/dspy-rb
|
|
266
|
+
```
|
|
267
|
+
|
|
268
|
+
**Claude.ai (Pro/Max):** Download the [skill as a ZIP](https://github.com/vicentereig/dspy-rb-skill/archive/refs/heads/main.zip) and upload via Settings > Skills.
|
|
257
269
|
|
|
258
270
|
### Getting Started
|
|
259
271
|
- **[Installation & Setup](docs/src/getting-started/installation.md)** - Detailed installation and configuration
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'sorbet-runtime'
|
|
4
|
+
require 'net/http'
|
|
5
|
+
require 'json'
|
|
6
|
+
require 'base64'
|
|
7
|
+
|
|
8
|
+
module DSPy
|
|
9
|
+
class Observability
|
|
10
|
+
module Adapters
|
|
11
|
+
module Langfuse
|
|
12
|
+
# Async exporter for sending scores to Langfuse REST API
|
|
13
|
+
# Uses a background thread to avoid blocking the main application
|
|
14
|
+
class ScoresExporter
|
|
15
|
+
extend T::Sig
|
|
16
|
+
|
|
17
|
+
DEFAULT_HOST = 'https://cloud.langfuse.com'
|
|
18
|
+
SCORES_ENDPOINT = '/api/public/scores'
|
|
19
|
+
DEFAULT_MAX_RETRIES = 3
|
|
20
|
+
DEFAULT_TIMEOUT = 10
|
|
21
|
+
|
|
22
|
+
attr_reader :host
|
|
23
|
+
|
|
24
|
+
sig do
|
|
25
|
+
params(
|
|
26
|
+
public_key: String,
|
|
27
|
+
secret_key: String,
|
|
28
|
+
host: String,
|
|
29
|
+
max_retries: Integer,
|
|
30
|
+
timeout: Integer
|
|
31
|
+
).void
|
|
32
|
+
end
|
|
33
|
+
def initialize(
|
|
34
|
+
public_key:,
|
|
35
|
+
secret_key:,
|
|
36
|
+
host: DEFAULT_HOST,
|
|
37
|
+
max_retries: DEFAULT_MAX_RETRIES,
|
|
38
|
+
timeout: DEFAULT_TIMEOUT
|
|
39
|
+
)
|
|
40
|
+
@public_key = public_key
|
|
41
|
+
@secret_key = secret_key
|
|
42
|
+
@host = host.chomp('/')
|
|
43
|
+
@max_retries = max_retries
|
|
44
|
+
@timeout = timeout
|
|
45
|
+
@queue = Thread::Queue.new
|
|
46
|
+
@running = false
|
|
47
|
+
@worker_thread = nil
|
|
48
|
+
@subscription_id = nil
|
|
49
|
+
@mutex = Mutex.new
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Factory method that creates, starts, and subscribes to events
|
|
53
|
+
sig do
|
|
54
|
+
params(
|
|
55
|
+
public_key: String,
|
|
56
|
+
secret_key: String,
|
|
57
|
+
host: String,
|
|
58
|
+
max_retries: Integer,
|
|
59
|
+
timeout: Integer
|
|
60
|
+
).returns(ScoresExporter)
|
|
61
|
+
end
|
|
62
|
+
def self.configure(
|
|
63
|
+
public_key:,
|
|
64
|
+
secret_key:,
|
|
65
|
+
host: DEFAULT_HOST,
|
|
66
|
+
max_retries: DEFAULT_MAX_RETRIES,
|
|
67
|
+
timeout: DEFAULT_TIMEOUT
|
|
68
|
+
)
|
|
69
|
+
exporter = new(
|
|
70
|
+
public_key: public_key,
|
|
71
|
+
secret_key: secret_key,
|
|
72
|
+
host: host,
|
|
73
|
+
max_retries: max_retries,
|
|
74
|
+
timeout: timeout
|
|
75
|
+
)
|
|
76
|
+
exporter.start
|
|
77
|
+
exporter.subscribe_to_events
|
|
78
|
+
exporter
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
sig { void }
|
|
82
|
+
def start
|
|
83
|
+
@mutex.synchronize do
|
|
84
|
+
return if @running
|
|
85
|
+
|
|
86
|
+
@running = true
|
|
87
|
+
@worker_thread = Thread.new { process_queue }
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
sig { returns(T::Boolean) }
|
|
92
|
+
def running?
|
|
93
|
+
@mutex.synchronize { @running }
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
sig { params(score_event: DSPy::Scores::ScoreEvent).void }
|
|
97
|
+
def export(score_event)
|
|
98
|
+
return unless running?
|
|
99
|
+
|
|
100
|
+
@queue.push(score_event)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
sig { returns(Integer) }
|
|
104
|
+
def queue_size
|
|
105
|
+
@queue.size
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
sig { void }
|
|
109
|
+
def subscribe_to_events
|
|
110
|
+
@subscription_id = DSPy.events.subscribe('score.create') do |_name, attrs|
|
|
111
|
+
# Reconstruct ScoreEvent from event attributes
|
|
112
|
+
score_event = DSPy::Scores::ScoreEvent.new(
|
|
113
|
+
id: attrs[:score_id],
|
|
114
|
+
name: attrs[:score_name],
|
|
115
|
+
value: attrs[:score_value],
|
|
116
|
+
data_type: DSPy::Scores::DataType.deserialize(attrs[:score_data_type]),
|
|
117
|
+
comment: attrs[:score_comment],
|
|
118
|
+
trace_id: attrs[:trace_id],
|
|
119
|
+
observation_id: attrs[:observation_id]
|
|
120
|
+
)
|
|
121
|
+
export(score_event)
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
sig { params(timeout: Integer).void }
|
|
126
|
+
def shutdown(timeout: 5)
|
|
127
|
+
@mutex.synchronize do
|
|
128
|
+
return unless @running
|
|
129
|
+
|
|
130
|
+
@running = false
|
|
131
|
+
|
|
132
|
+
# Unsubscribe from events
|
|
133
|
+
DSPy.events.unsubscribe(@subscription_id) if @subscription_id
|
|
134
|
+
@subscription_id = nil
|
|
135
|
+
|
|
136
|
+
# Signal worker to stop
|
|
137
|
+
@queue.push(:stop)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# Wait for worker thread to finish
|
|
141
|
+
@worker_thread&.join(timeout)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
private
|
|
145
|
+
|
|
146
|
+
sig { void }
|
|
147
|
+
def process_queue
|
|
148
|
+
while running? || !@queue.empty?
|
|
149
|
+
item = @queue.pop
|
|
150
|
+
|
|
151
|
+
break if item == :stop
|
|
152
|
+
|
|
153
|
+
begin
|
|
154
|
+
send_with_retry(item)
|
|
155
|
+
rescue StandardError => e
|
|
156
|
+
DSPy.log('scores.export_error', error: e.message, score_name: item.name)
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
sig { params(score_event: DSPy::Scores::ScoreEvent).void }
|
|
162
|
+
def send_with_retry(score_event)
|
|
163
|
+
retries = 0
|
|
164
|
+
|
|
165
|
+
begin
|
|
166
|
+
send_to_langfuse(score_event)
|
|
167
|
+
rescue StandardError => e
|
|
168
|
+
retries += 1
|
|
169
|
+
if retries <= @max_retries
|
|
170
|
+
sleep(exponential_backoff(retries))
|
|
171
|
+
retry
|
|
172
|
+
else
|
|
173
|
+
raise e
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
sig { params(attempt: Integer).returns(Float) }
|
|
179
|
+
def exponential_backoff(attempt)
|
|
180
|
+
# 0.1s, 0.2s, 0.4s, 0.8s... with jitter
|
|
181
|
+
base_delay = 0.1 * (2 ** (attempt - 1))
|
|
182
|
+
base_delay + rand * 0.1
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
sig { params(score_event: DSPy::Scores::ScoreEvent).void }
|
|
186
|
+
def send_to_langfuse(score_event)
|
|
187
|
+
uri = URI("#{@host}#{SCORES_ENDPOINT}")
|
|
188
|
+
payload = build_payload(score_event)
|
|
189
|
+
|
|
190
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
191
|
+
http.use_ssl = uri.scheme == 'https'
|
|
192
|
+
http.open_timeout = @timeout
|
|
193
|
+
http.read_timeout = @timeout
|
|
194
|
+
|
|
195
|
+
request = Net::HTTP::Post.new(uri.path)
|
|
196
|
+
request['Content-Type'] = 'application/json'
|
|
197
|
+
request['Authorization'] = "Basic #{auth_token}"
|
|
198
|
+
request.body = JSON.generate(payload)
|
|
199
|
+
|
|
200
|
+
response = http.request(request)
|
|
201
|
+
|
|
202
|
+
unless response.is_a?(Net::HTTPSuccess)
|
|
203
|
+
raise "Langfuse API error: #{response.code} - #{response.body}"
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
DSPy.log('scores.exported', score_name: score_event.name, score_id: score_event.id)
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
sig { params(score_event: DSPy::Scores::ScoreEvent).returns(T::Hash[Symbol, T.untyped]) }
|
|
210
|
+
def build_payload(score_event)
|
|
211
|
+
score_event.to_langfuse_payload
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
sig { returns(String) }
|
|
215
|
+
def auth_token
|
|
216
|
+
Base64.strict_encode64("#{@public_key}:#{@secret_key}")
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
end
|
data/lib/dspy/o11y/langfuse.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: dspy-o11y-langfuse
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.0
|
|
4
|
+
version: 1.1.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Vicente Reig Rincón de Arellano
|
|
@@ -62,6 +62,7 @@ files:
|
|
|
62
62
|
- LICENSE
|
|
63
63
|
- README.md
|
|
64
64
|
- lib/dspy/o11y/langfuse.rb
|
|
65
|
+
- lib/dspy/o11y/langfuse/scores_exporter.rb
|
|
65
66
|
- lib/dspy/o11y/langfuse/version.rb
|
|
66
67
|
homepage: https://github.com/vicentereig/dspy.rb
|
|
67
68
|
licenses:
|