prompt_warden 0.1.0 → 0.1.1
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/.gitignore +3 -0
- data/CHANGELOG.md +33 -2
- data/Gemfile +4 -4
- data/Gemfile.lock +21 -1
- data/README.md +217 -19
- data/Rakefile +19 -2
- data/bin/console +3 -3
- data/bin/pw_tail +8 -0
- data/examples/policy.yml +22 -0
- data/lib/prompt_warden/adapter.rb +59 -0
- data/lib/prompt_warden/buffer.rb +60 -0
- data/lib/prompt_warden/cli.rb +199 -0
- data/lib/prompt_warden/configuration.rb +39 -0
- data/lib/prompt_warden/cost_calculator.rb +105 -0
- data/lib/prompt_warden/event.rb +18 -0
- data/lib/prompt_warden/instrumentation/anthropic.rb +85 -0
- data/lib/prompt_warden/instrumentation/langchain.rb +76 -0
- data/lib/prompt_warden/instrumentation/openai.rb +79 -0
- data/lib/prompt_warden/policy.rb +73 -0
- data/lib/prompt_warden/railtie.rb +15 -0
- data/lib/prompt_warden/uploader.rb +93 -0
- data/lib/prompt_warden/version.rb +1 -1
- data/lib/prompt_warden.rb +32 -3
- data/prompt_warden.gemspec +33 -25
- data/spec/adapter_auto_detect_spec.rb +65 -0
- data/spec/anthropic_adapter_spec.rb +137 -0
- data/spec/buffer_spec.rb +44 -0
- data/spec/cli_spec.rb +255 -0
- data/spec/configuration_spec.rb +30 -0
- data/spec/cost_calculator_spec.rb +216 -0
- data/spec/event_spec.rb +30 -0
- data/spec/langchain_adapter_spec.rb +139 -0
- data/spec/openai_adapter_spec.rb +153 -0
- data/spec/policy_spec.rb +170 -0
- data/spec/prompt_warden_spec.rb +2 -2
- data/spec/spec_helper.rb +7 -8
- data/spec/uploader_spec.rb +79 -0
- metadata +98 -15
data/spec/cli_spec.rb
ADDED
@@ -0,0 +1,255 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'time'
|
4
|
+
|
5
|
+
RSpec.describe PromptWarden::CLI do
|
6
|
+
describe PromptWarden::CLI::EventFormatter do
|
7
|
+
let(:event) do
|
8
|
+
PromptWarden::Event.new(
|
9
|
+
id: 'test-id',
|
10
|
+
prompt: 'What is the ETA for this project?',
|
11
|
+
response: 'The ETA is 2 weeks',
|
12
|
+
model: 'gpt-4o',
|
13
|
+
latency_ms: 1250,
|
14
|
+
cost_usd: 0.005,
|
15
|
+
status: 'ok',
|
16
|
+
timestamp: '2024-01-15T10:30:00Z',
|
17
|
+
alerts: [
|
18
|
+
{ 'type' => 'regex', 'rule' => '/ETA/i', 'level' => 'warn' }
|
19
|
+
]
|
20
|
+
)
|
21
|
+
end
|
22
|
+
|
23
|
+
describe '.format' do
|
24
|
+
it 'formats events in human readable format by default' do
|
25
|
+
output = described_class.format(event)
|
26
|
+
expect(output).to include('10:30:00')
|
27
|
+
expect(output).to include('gpt-4o')
|
28
|
+
expect(output).to include('$0.005')
|
29
|
+
expect(output).to include('ok')
|
30
|
+
expect(output).to include('⚠️ /ETA/i')
|
31
|
+
expect(output).to include('What is the ETA for this project?')
|
32
|
+
end
|
33
|
+
|
34
|
+
it 'formats events in JSON format when requested' do
|
35
|
+
output = described_class.format(event, json: true)
|
36
|
+
parsed = JSON.parse(output)
|
37
|
+
expect(parsed['id']).to eq('test-id')
|
38
|
+
expect(parsed['model']).to eq('gpt-4o')
|
39
|
+
expect(parsed['alerts']).to eq([{ 'type' => 'regex', 'rule' => '/ETA/i', 'level' => 'warn' }])
|
40
|
+
end
|
41
|
+
|
42
|
+
it 'handles events with cost alerts' do
|
43
|
+
cost_event = PromptWarden::Event.new(
|
44
|
+
model: 'gpt-4o',
|
45
|
+
cost_usd: 0.75,
|
46
|
+
alerts: [{ 'type' => 'cost', 'limit' => 0.50, 'level' => 'block' }]
|
47
|
+
)
|
48
|
+
output = described_class.format(cost_event)
|
49
|
+
expect(output).to include('💰 >$0.5')
|
50
|
+
end
|
51
|
+
|
52
|
+
it 'handles events with multiple alerts' do
|
53
|
+
multi_event = PromptWarden::Event.new(
|
54
|
+
model: 'gpt-4o',
|
55
|
+
alerts: [
|
56
|
+
{ 'type' => 'regex', 'rule' => '/ETA/i', 'level' => 'warn' },
|
57
|
+
{ 'type' => 'regex', 'rule' => '/urgent/i', 'level' => 'warn' }
|
58
|
+
]
|
59
|
+
)
|
60
|
+
output = described_class.format(multi_event)
|
61
|
+
expect(output).to include('⚠️ /ETA/i')
|
62
|
+
expect(output).to include('⚠️ /urgent/i')
|
63
|
+
end
|
64
|
+
|
65
|
+
it 'handles events with no alerts' do
|
66
|
+
no_alert_event = PromptWarden::Event.new(
|
67
|
+
model: 'gpt-4o',
|
68
|
+
alerts: []
|
69
|
+
)
|
70
|
+
output = described_class.format(no_alert_event)
|
71
|
+
expect(output).not_to include('⚠️')
|
72
|
+
expect(output).not_to include('💰')
|
73
|
+
end
|
74
|
+
|
75
|
+
it 'truncates long prompts' do
|
76
|
+
long_prompt = 'A' * 100
|
77
|
+
long_event = PromptWarden::Event.new(
|
78
|
+
model: 'gpt-4o',
|
79
|
+
prompt: long_prompt
|
80
|
+
)
|
81
|
+
output = described_class.format(long_event)
|
82
|
+
expect(output).to include('A' * 50 + '...')
|
83
|
+
end
|
84
|
+
|
85
|
+
it 'handles missing fields gracefully' do
|
86
|
+
minimal_event = PromptWarden::Event.new
|
87
|
+
output = described_class.format(minimal_event)
|
88
|
+
expect(output).to include('unknown')
|
89
|
+
expect(output).to include('N/A')
|
90
|
+
end
|
91
|
+
end
|
92
|
+
end
|
93
|
+
|
94
|
+
describe PromptWarden::CLI::EventFilter do
|
95
|
+
let(:event) do
|
96
|
+
PromptWarden::Event.new(
|
97
|
+
model: 'gpt-4o',
|
98
|
+
cost_usd: 0.005,
|
99
|
+
status: 'ok',
|
100
|
+
alerts: [{ 'type' => 'regex', 'rule' => '/ETA/i', 'level' => 'warn' }]
|
101
|
+
)
|
102
|
+
end
|
103
|
+
|
104
|
+
describe '.matches?' do
|
105
|
+
it 'matches events with no filters' do
|
106
|
+
expect(described_class.matches?(event, {})).to be true
|
107
|
+
end
|
108
|
+
|
109
|
+
it 'filters by alerts' do
|
110
|
+
expect(described_class.matches?(event, { alerts: true })).to be true
|
111
|
+
|
112
|
+
no_alert_event = PromptWarden::Event.new(alerts: [])
|
113
|
+
expect(described_class.matches?(no_alert_event, { alerts: true })).to be false
|
114
|
+
end
|
115
|
+
|
116
|
+
it 'filters by model' do
|
117
|
+
expect(described_class.matches?(event, { model: 'gpt-4o' })).to be true
|
118
|
+
expect(described_class.matches?(event, { model: 'claude-3' })).to be false
|
119
|
+
end
|
120
|
+
|
121
|
+
it 'filters by cost' do
|
122
|
+
expect(described_class.matches?(event, { cost: 0.01 })).to be false # 0.005 < 0.01
|
123
|
+
expect(described_class.matches?(event, { cost: 0.001 })).to be true # 0.005 > 0.001
|
124
|
+
end
|
125
|
+
|
126
|
+
it 'filters by status' do
|
127
|
+
expect(described_class.matches?(event, { status: 'ok' })).to be true
|
128
|
+
expect(described_class.matches?(event, { status: 'failed' })).to be false
|
129
|
+
end
|
130
|
+
|
131
|
+
it 'combines multiple filters' do
|
132
|
+
expect(described_class.matches?(event, {
|
133
|
+
alerts: true,
|
134
|
+
model: 'gpt-4o',
|
135
|
+
status: 'ok'
|
136
|
+
})).to be true
|
137
|
+
|
138
|
+
expect(described_class.matches?(event, {
|
139
|
+
alerts: true,
|
140
|
+
model: 'claude-3' # Wrong model
|
141
|
+
})).to be false
|
142
|
+
end
|
143
|
+
|
144
|
+
it 'handles events with missing cost' do
|
145
|
+
no_cost_event = PromptWarden::Event.new(model: 'gpt-4o')
|
146
|
+
expect(described_class.matches?(no_cost_event, { cost: 0.01 })).to be false
|
147
|
+
end
|
148
|
+
end
|
149
|
+
end
|
150
|
+
|
151
|
+
describe PromptWarden::CLI::CLIBuffer do
|
152
|
+
let(:buffer) { described_class.new }
|
153
|
+
let(:event) { PromptWarden::Event.new(id: 'test-id') }
|
154
|
+
|
155
|
+
describe '#push' do
|
156
|
+
it 'stores events' do
|
157
|
+
buffer.push(event)
|
158
|
+
expect(buffer.recent_events).to include(event)
|
159
|
+
end
|
160
|
+
|
161
|
+
it 'calls listeners when events are pushed' do
|
162
|
+
listener_called = false
|
163
|
+
buffer.on_event { listener_called = true }
|
164
|
+
buffer.push(event)
|
165
|
+
expect(listener_called).to be true
|
166
|
+
end
|
167
|
+
|
168
|
+
it 'calls multiple listeners' do
|
169
|
+
calls = []
|
170
|
+
buffer.on_event { calls << 1 }
|
171
|
+
buffer.on_event { calls << 2 }
|
172
|
+
buffer.push(event)
|
173
|
+
expect(calls).to eq([1, 2])
|
174
|
+
end
|
175
|
+
end
|
176
|
+
|
177
|
+
describe '#recent_events' do
|
178
|
+
it 'returns recent events with limit' do
|
179
|
+
5.times { |i| buffer.push(PromptWarden::Event.new(id: "event-#{i}")) }
|
180
|
+
recent = buffer.recent_events(limit: 3)
|
181
|
+
expect(recent.length).to eq(3)
|
182
|
+
expect(recent.last.id).to eq('event-4')
|
183
|
+
end
|
184
|
+
|
185
|
+
it 'returns all events if limit exceeds count' do
|
186
|
+
3.times { |i| buffer.push(PromptWarden::Event.new(id: "event-#{i}")) }
|
187
|
+
recent = buffer.recent_events(limit: 10)
|
188
|
+
expect(recent.length).to eq(3)
|
189
|
+
end
|
190
|
+
end
|
191
|
+
end
|
192
|
+
|
193
|
+
describe PromptWarden::CLI::Tail do
|
194
|
+
describe '.parse_options' do
|
195
|
+
it 'parses no arguments' do
|
196
|
+
options = described_class.parse_options([])
|
197
|
+
expect(options[:alerts]).to be false
|
198
|
+
expect(options[:follow]).to be true
|
199
|
+
expect(options[:json]).to be false
|
200
|
+
end
|
201
|
+
|
202
|
+
it 'parses --alerts flag' do
|
203
|
+
options = described_class.parse_options(['--alerts'])
|
204
|
+
expect(options[:alerts]).to be true
|
205
|
+
end
|
206
|
+
|
207
|
+
it 'parses --model argument' do
|
208
|
+
options = described_class.parse_options(['--model', 'gpt-4o'])
|
209
|
+
expect(options[:model]).to eq('gpt-4o')
|
210
|
+
end
|
211
|
+
|
212
|
+
it 'parses --cost argument' do
|
213
|
+
options = described_class.parse_options(['--cost', '0.50'])
|
214
|
+
expect(options[:cost]).to eq(0.50)
|
215
|
+
end
|
216
|
+
|
217
|
+
it 'parses --status argument' do
|
218
|
+
options = described_class.parse_options(['--status', 'failed'])
|
219
|
+
expect(options[:status]).to eq('failed')
|
220
|
+
end
|
221
|
+
|
222
|
+
it 'parses --limit argument' do
|
223
|
+
options = described_class.parse_options(['--limit', '5'])
|
224
|
+
expect(options[:limit]).to eq(5)
|
225
|
+
end
|
226
|
+
|
227
|
+
it 'parses --json flag' do
|
228
|
+
options = described_class.parse_options(['--json'])
|
229
|
+
expect(options[:json]).to be true
|
230
|
+
end
|
231
|
+
|
232
|
+
it 'parses --no-follow flag' do
|
233
|
+
options = described_class.parse_options(['--no-follow'])
|
234
|
+
expect(options[:follow]).to be false
|
235
|
+
end
|
236
|
+
end
|
237
|
+
|
238
|
+
describe '.configure_prompt_warden' do
|
239
|
+
it 'does not override existing configuration' do
|
240
|
+
PromptWarden.configure do |config|
|
241
|
+
config.project_token = 'existing-token'
|
242
|
+
end
|
243
|
+
|
244
|
+
described_class.configure_prompt_warden
|
245
|
+
|
246
|
+
expect(PromptWarden.configuration.project_token).to eq('existing-token')
|
247
|
+
end
|
248
|
+
|
249
|
+
it 'sets CLI configuration when no existing config' do
|
250
|
+
# Test that the method doesn't raise errors
|
251
|
+
expect { described_class.configure_prompt_warden }.not_to raise_error
|
252
|
+
end
|
253
|
+
end
|
254
|
+
end
|
255
|
+
end
|
@@ -0,0 +1,30 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
RSpec.describe PromptWarden::Configuration do
|
4
|
+
subject(:config) { described_class.new }
|
5
|
+
|
6
|
+
it 'defaults flush_interval to 1 second' do
|
7
|
+
expect(config.flush_interval).to eq 1.0
|
8
|
+
end
|
9
|
+
|
10
|
+
it 'pulls project token from ENV by default' do
|
11
|
+
with_env('PROMPT_WARDEN_TOKEN' => 'env_tok') do
|
12
|
+
expect(described_class.new.project_token).to eq 'env_tok'
|
13
|
+
end
|
14
|
+
end
|
15
|
+
|
16
|
+
it 'raises if project_token missing after configure' do
|
17
|
+
expect do
|
18
|
+
PromptWarden.configure { |c| c.project_token = nil }
|
19
|
+
end.to raise_error(ArgumentError)
|
20
|
+
end
|
21
|
+
|
22
|
+
# helper
|
23
|
+
def with_env(env)
|
24
|
+
old = ENV.to_h.slice(*env.keys)
|
25
|
+
env.each { |k, v| ENV[k] = v }
|
26
|
+
yield
|
27
|
+
ensure
|
28
|
+
old.each { |k, v| ENV[k] = v }
|
29
|
+
end
|
30
|
+
end
|
@@ -0,0 +1,216 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
RSpec.describe PromptWarden::CostCalculator do
|
4
|
+
describe '.calculate_cost' do
|
5
|
+
it 'calculates cost for OpenAI models' do
|
6
|
+
cost = described_class.calculate_cost(
|
7
|
+
prompt: 'Hello, world!',
|
8
|
+
model: 'gpt-4o'
|
9
|
+
)
|
10
|
+
expect(cost).to be > 0
|
11
|
+
expect(cost).to be < 0.01 # Should be very small for short prompt
|
12
|
+
end
|
13
|
+
|
14
|
+
it 'calculates cost for Anthropic models' do
|
15
|
+
cost = described_class.calculate_cost(
|
16
|
+
prompt: 'Hello, world!',
|
17
|
+
model: 'claude-3-sonnet-20240229'
|
18
|
+
)
|
19
|
+
expect(cost).to be > 0
|
20
|
+
expect(cost).to be < 0.01
|
21
|
+
end
|
22
|
+
|
23
|
+
it 'uses response tokens when provided' do
|
24
|
+
cost = described_class.calculate_cost(
|
25
|
+
prompt: 'Hello, world!',
|
26
|
+
model: 'gpt-4o',
|
27
|
+
response_tokens: 100
|
28
|
+
)
|
29
|
+
expect(cost).to be > 0
|
30
|
+
end
|
31
|
+
|
32
|
+
it 'handles nil prompt' do
|
33
|
+
cost = described_class.calculate_cost(
|
34
|
+
prompt: nil,
|
35
|
+
model: 'gpt-4o'
|
36
|
+
)
|
37
|
+
expect(cost).to eq(0)
|
38
|
+
end
|
39
|
+
|
40
|
+
it 'handles empty prompt' do
|
41
|
+
cost = described_class.calculate_cost(
|
42
|
+
prompt: '',
|
43
|
+
model: 'gpt-4o'
|
44
|
+
)
|
45
|
+
expect(cost).to eq(0)
|
46
|
+
end
|
47
|
+
|
48
|
+
it 'uses default pricing for unknown models' do
|
49
|
+
cost = described_class.calculate_cost(
|
50
|
+
prompt: 'Hello, world!',
|
51
|
+
model: 'unknown-model'
|
52
|
+
)
|
53
|
+
expect(cost).to be > 0
|
54
|
+
end
|
55
|
+
end
|
56
|
+
|
57
|
+
describe '.count_tokens' do
|
58
|
+
it 'counts tokens for text' do
|
59
|
+
count = described_class.count_tokens('Hello, world!')
|
60
|
+
expect(count).to be > 0
|
61
|
+
end
|
62
|
+
|
63
|
+
it 'returns 0 for nil text' do
|
64
|
+
count = described_class.count_tokens(nil)
|
65
|
+
expect(count).to eq(0)
|
66
|
+
end
|
67
|
+
|
68
|
+
it 'returns 0 for empty text' do
|
69
|
+
count = described_class.count_tokens('')
|
70
|
+
expect(count).to eq(0)
|
71
|
+
end
|
72
|
+
|
73
|
+
it 'uses approximate counting for non-OpenAI models' do
|
74
|
+
count = described_class.count_tokens('Hello, world!', 'claude-3-sonnet')
|
75
|
+
expect(count).to be > 0
|
76
|
+
end
|
77
|
+
|
78
|
+
context 'with tiktoken available' do
|
79
|
+
before do
|
80
|
+
# Mock tiktoken if not available
|
81
|
+
unless defined?(Tiktoken)
|
82
|
+
stub_const('Tiktoken', Class.new)
|
83
|
+
allow(Tiktoken).to receive(:encoding_for_model).and_return(
|
84
|
+
double('encoding', encode: [1, 2, 3, 4, 5])
|
85
|
+
)
|
86
|
+
end
|
87
|
+
end
|
88
|
+
|
89
|
+
it 'uses tiktoken for OpenAI models' do
|
90
|
+
count = described_class.count_tokens('Hello, world!', 'gpt-4o')
|
91
|
+
expect(count).to eq(5)
|
92
|
+
end
|
93
|
+
|
94
|
+
it 'falls back to approximate counting if tiktoken fails' do
|
95
|
+
allow(Tiktoken).to receive(:encoding_for_model).and_raise(StandardError.new('tiktoken error'))
|
96
|
+
|
97
|
+
count = described_class.count_tokens('Hello, world!', 'gpt-4o')
|
98
|
+
expect(count).to be > 0
|
99
|
+
end
|
100
|
+
end
|
101
|
+
end
|
102
|
+
|
103
|
+
describe '.estimate_output_tokens' do
|
104
|
+
it 'estimates output tokens based on prompt length' do
|
105
|
+
tokens = described_class.estimate_output_tokens('Hello, world!', 'gpt-4o')
|
106
|
+
expect(tokens).to be > 0
|
107
|
+
end
|
108
|
+
|
109
|
+
it 'adjusts estimation based on model type' do
|
110
|
+
short_prompt = 'Hi'
|
111
|
+
|
112
|
+
gpt4o_tokens = described_class.estimate_output_tokens(short_prompt, 'gpt-4o')
|
113
|
+
claude_opus_tokens = described_class.estimate_output_tokens(short_prompt, 'claude-3-opus-20240229')
|
114
|
+
claude_haiku_tokens = described_class.estimate_output_tokens(short_prompt, 'claude-3-haiku-20240307')
|
115
|
+
|
116
|
+
# Claude Opus should estimate more tokens (more verbose)
|
117
|
+
expect(claude_opus_tokens).to be >= gpt4o_tokens
|
118
|
+
# Claude Haiku should estimate fewer tokens (more concise)
|
119
|
+
expect(claude_haiku_tokens).to be <= gpt4o_tokens
|
120
|
+
end
|
121
|
+
|
122
|
+
it 'handles nil model' do
|
123
|
+
tokens = described_class.estimate_output_tokens('Hello, world!', nil)
|
124
|
+
expect(tokens).to be > 0
|
125
|
+
end
|
126
|
+
end
|
127
|
+
|
128
|
+
describe '.get_model_pricing' do
|
129
|
+
it 'returns exact match pricing' do
|
130
|
+
pricing = described_class.get_model_pricing('gpt-4o')
|
131
|
+
expect(pricing).to eq({ input: 0.0025, output: 0.01 })
|
132
|
+
end
|
133
|
+
|
134
|
+
it 'returns default pricing for unknown models' do
|
135
|
+
pricing = described_class.get_model_pricing('unknown-model')
|
136
|
+
expect(pricing).to eq({ input: 0.001, output: 0.002 })
|
137
|
+
end
|
138
|
+
|
139
|
+
it 'handles nil model' do
|
140
|
+
pricing = described_class.get_model_pricing(nil)
|
141
|
+
expect(pricing).to eq({ input: 0.001, output: 0.002 })
|
142
|
+
end
|
143
|
+
|
144
|
+
it 'finds partial matches for model variants' do
|
145
|
+
# Test that it can find pricing for model variants
|
146
|
+
pricing = described_class.get_model_pricing('gpt-4o-mini')
|
147
|
+
expect(pricing).to eq({ input: 0.00015, output: 0.0006 })
|
148
|
+
end
|
149
|
+
end
|
150
|
+
|
151
|
+
describe 'MODEL_PRICING' do
|
152
|
+
it 'includes major OpenAI models' do
|
153
|
+
expect(described_class::MODEL_PRICING).to include('gpt-4o')
|
154
|
+
expect(described_class::MODEL_PRICING).to include('gpt-4o-mini')
|
155
|
+
expect(described_class::MODEL_PRICING).to include('gpt-3.5-turbo')
|
156
|
+
end
|
157
|
+
|
158
|
+
it 'includes major Anthropic models' do
|
159
|
+
expect(described_class::MODEL_PRICING).to include('claude-3-opus-20240229')
|
160
|
+
expect(described_class::MODEL_PRICING).to include('claude-3-sonnet-20240229')
|
161
|
+
expect(described_class::MODEL_PRICING).to include('claude-3-haiku-20240307')
|
162
|
+
end
|
163
|
+
|
164
|
+
it 'includes default pricing' do
|
165
|
+
expect(described_class::MODEL_PRICING).to include('default')
|
166
|
+
end
|
167
|
+
|
168
|
+
it 'has correct pricing structure' do
|
169
|
+
described_class::MODEL_PRICING.each do |model, pricing|
|
170
|
+
expect(pricing).to have_key(:input)
|
171
|
+
expect(pricing).to have_key(:output)
|
172
|
+
expect(pricing[:input]).to be > 0
|
173
|
+
expect(pricing[:output]).to be > 0
|
174
|
+
end
|
175
|
+
end
|
176
|
+
end
|
177
|
+
|
178
|
+
describe 'integration with PromptWarden' do
|
179
|
+
it 'can be called through PromptWarden.calculate_cost' do
|
180
|
+
cost = PromptWarden.calculate_cost(
|
181
|
+
prompt: 'Hello, world!',
|
182
|
+
model: 'gpt-4o'
|
183
|
+
)
|
184
|
+
expect(cost).to be > 0
|
185
|
+
end
|
186
|
+
|
187
|
+
it 'handles different prompt lengths' do
|
188
|
+
short_cost = PromptWarden.calculate_cost(
|
189
|
+
prompt: 'Hi',
|
190
|
+
model: 'gpt-4o'
|
191
|
+
)
|
192
|
+
|
193
|
+
long_cost = PromptWarden.calculate_cost(
|
194
|
+
prompt: 'This is a much longer prompt that should cost more to process',
|
195
|
+
model: 'gpt-4o'
|
196
|
+
)
|
197
|
+
|
198
|
+
expect(long_cost).to be > short_cost
|
199
|
+
end
|
200
|
+
|
201
|
+
it 'calculates different costs for different models' do
|
202
|
+
gpt4o_cost = PromptWarden.calculate_cost(
|
203
|
+
prompt: 'Hello, world!',
|
204
|
+
model: 'gpt-4o'
|
205
|
+
)
|
206
|
+
|
207
|
+
claude_cost = PromptWarden.calculate_cost(
|
208
|
+
prompt: 'Hello, world!',
|
209
|
+
model: 'claude-3-opus-20240229'
|
210
|
+
)
|
211
|
+
|
212
|
+
# Different models should have different costs
|
213
|
+
expect(gpt4o_cost).not_to eq(claude_cost)
|
214
|
+
end
|
215
|
+
end
|
216
|
+
end
|
data/spec/event_spec.rb
ADDED
@@ -0,0 +1,30 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
RSpec.describe PromptWarden::Event do
|
4
|
+
it 'serialises to hash with timestamp' do
|
5
|
+
evt = described_class.new(prompt: 'Hi', response: 'Hello')
|
6
|
+
expect(evt.to_h).to include(:timestamp)
|
7
|
+
end
|
8
|
+
|
9
|
+
it 'includes alerts field in serialization' do
|
10
|
+
evt = described_class.new(prompt: 'Hi', response: 'Hello')
|
11
|
+
expect(evt.to_h).to include(:alerts)
|
12
|
+
expect(evt.to_h[:alerts]).to eq []
|
13
|
+
end
|
14
|
+
|
15
|
+
it 'preserves alerts when provided' do
|
16
|
+
alerts = [{ type: 'regex', rule: '/ETA/i', level: 'warn' }]
|
17
|
+
evt = described_class.new(
|
18
|
+
prompt: 'Hi',
|
19
|
+
response: 'Hello',
|
20
|
+
alerts: alerts
|
21
|
+
)
|
22
|
+
expect(evt.to_h[:alerts]).to eq alerts
|
23
|
+
end
|
24
|
+
|
25
|
+
it 'defaults alerts to empty array when not provided' do
|
26
|
+
evt = described_class.new(prompt: 'Hi', response: 'Hello')
|
27
|
+
expect(evt.alerts).to eq nil
|
28
|
+
expect(evt.to_h[:alerts]).to eq []
|
29
|
+
end
|
30
|
+
end
|
@@ -0,0 +1,139 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative '../lib/prompt_warden/instrumentation/langchain'
|
4
|
+
|
5
|
+
RSpec.describe 'PromptWarden Langchain adapter' do
|
6
|
+
before do
|
7
|
+
require 'prompt_warden'
|
8
|
+
PromptWarden.configure { |c| c.project_token = 'tok' }
|
9
|
+
allow(PromptWarden).to receive(:record)
|
10
|
+
allow(PromptWarden::Policy.instance).to receive(:check_alerts).and_return([])
|
11
|
+
allow(PromptWarden).to receive(:calculate_cost).and_return(0.005)
|
12
|
+
end
|
13
|
+
|
14
|
+
it 'records prompt usage via instrumentation for chat method' do
|
15
|
+
client = Object.new
|
16
|
+
client.extend(PromptWarden::Instrumentation::Langchain)
|
17
|
+
# Call the instrumentation method directly
|
18
|
+
client.send(:_pw_wrap, :chat, {
|
19
|
+
prompt: 'hi',
|
20
|
+
model: 'gpt-4o'
|
21
|
+
}) do
|
22
|
+
'langchain reply'
|
23
|
+
end
|
24
|
+
expect(PromptWarden).to have_received(:record).with(hash_including(
|
25
|
+
prompt: 'hi',
|
26
|
+
model: 'gpt-4o',
|
27
|
+
cost_usd: 0.005,
|
28
|
+
status: 'ok',
|
29
|
+
alerts: []
|
30
|
+
))
|
31
|
+
end
|
32
|
+
|
33
|
+
it 'records prompt usage via instrumentation for complete method' do
|
34
|
+
client = Object.new
|
35
|
+
client.extend(PromptWarden::Instrumentation::Langchain)
|
36
|
+
# Call the instrumentation method directly
|
37
|
+
client.send(:_pw_wrap, :complete, {
|
38
|
+
prompt: 'hello',
|
39
|
+
model: 'claude-3-sonnet'
|
40
|
+
}) do
|
41
|
+
'langchain completion'
|
42
|
+
end
|
43
|
+
expect(PromptWarden).to have_received(:record).with(hash_including(
|
44
|
+
prompt: 'hello',
|
45
|
+
model: 'claude-3-sonnet',
|
46
|
+
cost_usd: 0.005,
|
47
|
+
status: 'ok',
|
48
|
+
alerts: []
|
49
|
+
))
|
50
|
+
end
|
51
|
+
|
52
|
+
it 'includes alerts when policy alerts are detected' do
|
53
|
+
alerts = [{ type: 'regex', rule: '/confidential/i', level: 'warn' }]
|
54
|
+
allow(PromptWarden::Policy.instance).to receive(:check_alerts).and_return(alerts)
|
55
|
+
client = Object.new
|
56
|
+
client.extend(PromptWarden::Instrumentation::Langchain)
|
57
|
+
# Call the instrumentation method directly
|
58
|
+
client.send(:_pw_wrap, :chat, {
|
59
|
+
prompt: 'This is confidential information',
|
60
|
+
model: 'gpt-4o'
|
61
|
+
}) do
|
62
|
+
'langchain reply'
|
63
|
+
end
|
64
|
+
expect(PromptWarden).to have_received(:record).with(hash_including(
|
65
|
+
prompt: 'This is confidential information',
|
66
|
+
model: 'gpt-4o',
|
67
|
+
status: 'ok',
|
68
|
+
alerts: alerts
|
69
|
+
))
|
70
|
+
end
|
71
|
+
|
72
|
+
it 'records failed status on error with alerts' do
|
73
|
+
alerts = [{ type: 'regex', rule: '/ETA/i', level: 'warn' }]
|
74
|
+
allow(PromptWarden::Policy.instance).to receive(:check_alerts).and_return(alerts)
|
75
|
+
client = Object.new
|
76
|
+
client.extend(PromptWarden::Instrumentation::Langchain)
|
77
|
+
expect {
|
78
|
+
client.send(:_pw_wrap, :chat, {
|
79
|
+
prompt: 'What is the ETA?',
|
80
|
+
model: 'gpt-4o'
|
81
|
+
}) do
|
82
|
+
raise 'test error'
|
83
|
+
end
|
84
|
+
}.to raise_error('test error')
|
85
|
+
expect(PromptWarden).to have_received(:record).with(hash_including(
|
86
|
+
prompt: 'What is the ETA?',
|
87
|
+
model: 'gpt-4o',
|
88
|
+
status: 'failed',
|
89
|
+
alerts: alerts
|
90
|
+
))
|
91
|
+
end
|
92
|
+
|
93
|
+
it 'extracts model from class name when not provided' do
|
94
|
+
client = Object.new
|
95
|
+
client.extend(PromptWarden::Instrumentation::Langchain)
|
96
|
+
allow(client).to receive(:class).and_return(double(name: 'Langchain::LLM::OpenAI'))
|
97
|
+
|
98
|
+
client.send(:_pw_wrap, :chat, {
|
99
|
+
prompt: 'hi'
|
100
|
+
}) do
|
101
|
+
'langchain reply'
|
102
|
+
end
|
103
|
+
|
104
|
+
expect(PromptWarden).to have_received(:record).with(hash_including(
|
105
|
+
prompt: 'hi',
|
106
|
+
model: 'openai',
|
107
|
+
status: 'ok',
|
108
|
+
alerts: []
|
109
|
+
))
|
110
|
+
end
|
111
|
+
|
112
|
+
it 'uses enhanced cost calculation for estimates and actual costs' do
|
113
|
+
client = Object.new
|
114
|
+
client.extend(PromptWarden::Instrumentation::Langchain)
|
115
|
+
|
116
|
+
# Mock cost calculation calls
|
117
|
+
allow(PromptWarden).to receive(:calculate_cost).with(
|
118
|
+
prompt: 'hi',
|
119
|
+
model: 'gpt-4o'
|
120
|
+
).and_return(0.003) # Estimate
|
121
|
+
|
122
|
+
allow(PromptWarden).to receive(:calculate_cost).with(
|
123
|
+
prompt: 'hi',
|
124
|
+
model: 'gpt-4o'
|
125
|
+
).and_return(0.005) # Actual cost (Langchain doesn't provide token counts)
|
126
|
+
|
127
|
+
client.send(:_pw_wrap, :chat, {
|
128
|
+
prompt: 'hi',
|
129
|
+
model: 'gpt-4o'
|
130
|
+
}) do
|
131
|
+
'langchain reply'
|
132
|
+
end
|
133
|
+
|
134
|
+
expect(PromptWarden).to have_received(:calculate_cost).with(
|
135
|
+
prompt: 'hi',
|
136
|
+
model: 'gpt-4o'
|
137
|
+
).twice # Once for estimate, once for actual
|
138
|
+
end
|
139
|
+
end
|