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.
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
@@ -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