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.
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'faraday'
4
+ require 'faraday/retry'
5
+ require 'singleton'
6
+ require 'tmpdir'
7
+ require 'fileutils'
8
+
9
+ module PromptWarden
10
+ class Uploader
11
+ include Singleton
12
+
13
+ def initialize
14
+ cfg = PromptWarden.configuration
15
+ @token = cfg.project_token
16
+ @logger = cfg.logger
17
+ api_url = cfg.respond_to?(:api_url) ? cfg.api_url : 'https://httpbin.org/post'
18
+
19
+ @client = Faraday.new(url: api_url) do |f|
20
+ f.request :retry,
21
+ max: cfg.max_retries,
22
+ interval: 0.05,
23
+ backoff_factor: 2,
24
+ methods: %i[get post],
25
+ retry_statuses: [500, 502, 503]
26
+ f.adapter :net_http
27
+ end
28
+
29
+ @queue = Queue.new
30
+ start_worker
31
+ end
32
+
33
+ # Enqueue compressed batch for async upload
34
+ def enqueue(payload)
35
+ @queue << payload
36
+ end
37
+
38
+ # Retry all failed uploads from disk
39
+ def self.retry_failed!
40
+ files = Dir.glob(File.join(Dir.tmpdir, 'pw_failed_*.json.gz'))
41
+ logger = PromptWarden.configuration.logger
42
+
43
+ if files.empty?
44
+ logger.info "No failed upload files found to retry"
45
+ return
46
+ end
47
+
48
+ logger.info "Found #{files.size} failed upload file(s) to retry"
49
+
50
+ files.each do |file|
51
+ begin
52
+ instance.enqueue(File.binread(file))
53
+ File.delete(file)
54
+ logger.info "Successfully retried and deleted: #{File.basename(file)}"
55
+ rescue => e
56
+ # If upload fails again, leave the file for next retry
57
+ logger.error "Retry failed for #{File.basename(file)}: #{e.class}: #{e.message}"
58
+ end
59
+ end
60
+ end
61
+
62
+ private
63
+
64
+ def start_worker
65
+ Thread.new do
66
+ loop do
67
+ payload = @queue.pop
68
+ begin
69
+ resp = @client.post do |req|
70
+ req.headers['Authorization'] = "Bearer #{@token}"
71
+ req.headers['Content-Encoding'] = 'gzip'
72
+ req.body = payload
73
+ end
74
+ @logger.info "PromptWarden upload: #{resp.status}"
75
+ rescue StandardError => e
76
+ @logger.error "PromptWarden upload failed: #{e.message}"
77
+ # Disk fallback: write failed batch to tmpdir
78
+ begin
79
+ dir = Dir.tmpdir
80
+ FileUtils.mkdir_p(dir) unless Dir.exist?(dir)
81
+ path = File.join(dir, 'pw_failed_') + Time.now.utc.strftime('%Y%m%d%H%M%S%L') + '.json.gz'
82
+ File.binwrite(path, payload)
83
+ @logger.info "PromptWarden wrote failed batch to #{path}"
84
+ rescue StandardError => file_err
85
+ @logger.error "PromptWarden failed to write fallback file: #{file_err.class}: #{file_err.message}"
86
+ end
87
+ # TODO: persist to disk for retry on process restart
88
+ end
89
+ end
90
+ end.tap { |t| t.name = 'PromptWarden::UploaderWorker' }
91
+ end
92
+ end
93
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module PromptWarden
4
- VERSION = "0.1.0"
4
+ VERSION = '0.1.1'
5
5
  end
data/lib/prompt_warden.rb CHANGED
@@ -1,8 +1,37 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "prompt_warden/version"
3
+ require_relative 'prompt_warden/version'
4
+ require_relative 'prompt_warden/configuration'
5
+ require_relative 'prompt_warden/railtie' if defined?(Rails)
6
+ require_relative 'prompt_warden/event'
7
+ require_relative 'prompt_warden/buffer'
8
+ require_relative 'prompt_warden/uploader'
9
+ require_relative 'prompt_warden/policy'
10
+ require_relative 'prompt_warden/adapter'
11
+ require_relative 'prompt_warden/cli'
12
+ require_relative 'prompt_warden/cost_calculator'
4
13
 
5
14
  module PromptWarden
6
- class Error < StandardError; end
7
- # Your code goes here...
15
+ class << self
16
+ def configuration = (@configuration ||= Configuration.new)
17
+ def configure = yield(configuration).tap { configuration.validate! }
18
+ def reset! = (@configuration = nil)
19
+
20
+ def record(event_attrs)
21
+ buffer.push(Event.new(**event_attrs))
22
+ end
23
+
24
+ def calculate_cost(prompt:, model:, response_tokens: nil)
25
+ CostCalculator.calculate_cost(prompt: prompt, model: model, response_tokens: response_tokens)
26
+ end
27
+
28
+ private
29
+
30
+ def buffer
31
+ @buffer ||= Buffer.new
32
+ end
33
+ end
8
34
  end
35
+
36
+ # Auto‑load adapters after core is ready
37
+ PromptWarden::Adapter.auto_load_all!
@@ -1,42 +1,50 @@
1
- # prompt_warden.gemspec
2
- require_relative "lib/prompt_warden/version"
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'lib/prompt_warden/version'
3
4
 
4
5
  Gem::Specification.new do |spec|
5
6
  # ---- Core identity --------------------------------------------------
6
- spec.name = "prompt_warden"
7
- spec.version = PromptWarden::VERSION # keep in one place
8
- spec.authors = ["Tyler Hammett"]
9
- spec.email = ["hello@promptwarden.io"]
10
- spec.summary = "Rails-native guard-rails & cost logging for LLM calls."
7
+ spec.name = 'prompt_warden'
8
+ spec.version = PromptWarden::VERSION # keep in one place
9
+ spec.authors = ['Tyler Hammett']
10
+ spec.email = ['hello@promptwarden.io']
11
+ spec.summary = 'Record, audit, and guard AI prompt usage with automatic SDK instrumentation.'
11
12
  spec.description = <<~DESC
12
- PromptWarden watches every OpenAI / Anthropic request,
13
- blocks risky prompts, and uploads JSON batches to the PromptWarden SaaS.
13
+ PromptWarden provides automatic instrumentation for OpenAI, Anthropic, and Langchain SDKs.
14
+ Features include policy enforcement, cost calculation, real-time monitoring, and alert recording.
15
+ Includes CLI tool for live event streaming and comprehensive test coverage.
14
16
  DESC
15
- spec.license = "MIT"
16
- spec.homepage = "https://promptwarden.io"
17
+ spec.license = 'MIT'
18
+ spec.homepage = 'https://promptwarden.io'
17
19
 
18
20
  # ---- Compatibility ---------------------------------------------------
19
- spec.required_ruby_version = ">= 3.1"
21
+ spec.required_ruby_version = '>= 3.1'
20
22
 
21
23
  # ---- Files & executables --------------------------------------------
22
- spec.files = Dir.chdir(File.expand_path(__dir__)) do
23
- `git ls-files -z`.split("\x0")
24
- end
25
- spec.bindir = "exe" # if you later add CLI binstubs
26
- spec.executables = [] # none yet
27
- spec.require_paths = ["lib"]
24
+ spec.files = Dir.chdir(File.expand_path(__dir__)) do
25
+ `git ls-files -z`.split("\x0")
26
+ end
27
+ spec.bindir = 'bin'
28
+ spec.executables = ['pw_tail']
29
+ spec.require_paths = ['lib']
28
30
 
29
31
  # ---- Runtime dependencies -------------------------------------------
30
32
  # HTTP client for uploads
31
- spec.add_runtime_dependency "faraday", "~> 2.9"
33
+ spec.add_runtime_dependency 'faraday', '~> 2.9'
34
+ spec.add_runtime_dependency 'faraday-retry', '~> 2.2'
35
+
36
+ # YAML for policy configuration
37
+ spec.add_runtime_dependency 'yaml'
32
38
 
33
39
  # ActiveSupport for Notifications
34
- spec.add_runtime_dependency "activesupport", ">= 6.1"
40
+ spec.add_runtime_dependency 'activesupport', '>= 6.1'
35
41
 
36
42
  # ---- Development / test dependencies -------------------------------
37
- spec.add_development_dependency "rspec", "~> 3.12"
38
- spec.add_development_dependency "rubocop", "~> 1.60"
39
- spec.add_development_dependency "rubocop-rspec", "~> 2.26"
40
- spec.add_development_dependency "bundler", ">= 2.3.0"
41
- spec.add_development_dependency "rake", "~> 13.2"
43
+ spec.add_development_dependency 'bundler', '>= 2.3.0'
44
+ spec.add_development_dependency 'rake', '~> 13.2'
45
+ spec.add_development_dependency 'rspec', '~> 3.12'
46
+ spec.add_development_dependency 'rubocop', '~> 1.60'
47
+ spec.add_development_dependency 'rubocop-rspec', '~> 2.26'
48
+ spec.add_development_dependency 'timecop', '~> 0.9'
49
+ spec.add_development_dependency 'webmock', '~> 3.20'
42
50
  end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ # --- Helper to stub an SDK constant --------------------------
4
+ module OpenAI
5
+ class Client
6
+ def initialize(api_key: nil, **opts)
7
+ @api_key = api_key
8
+ end
9
+
10
+ def chat(*); { "choices" => [{ "message" => { "content" => "hi" } }] }; end
11
+ end
12
+ end
13
+
14
+ def stub_openai_constant
15
+ # No-op: the constant is now always defined
16
+ PromptWarden::Adapter.auto_load_all!
17
+ if defined?(PromptWarden::Instrumentation::OpenAI)
18
+ OpenAI::Client.prepend(PromptWarden::Instrumentation::OpenAI)
19
+ end
20
+ end
21
+
22
+ RSpec.describe 'PromptWarden adapter auto‑detection' do
23
+ # ------------------------------------------------------------------
24
+ it 'loads adapter when gem is in Gem.loaded_specs' do
25
+ Gem.loaded_specs['openai'] = Gem::Specification.new('openai') # simulate Gemfile
26
+
27
+ require 'prompt_warden' # auto‑loader runs
28
+ stub_openai_constant # constant after load
29
+
30
+ PromptWarden.configure { |c| c.project_token = 'tok' }
31
+ expect(OpenAI::Client.ancestors)
32
+ .to include(PromptWarden::Instrumentation::OpenAI)
33
+ ensure
34
+ Gem.loaded_specs.delete('openai')
35
+ end
36
+
37
+ # ------------------------------------------------------------------
38
+ it 'loads adapter when constant exists (no gem spec)' do
39
+ stub_openai_constant # constant first
40
+ require 'prompt_warden' # auto‑loader sees it
41
+
42
+ PromptWarden.configure { |c| c.project_token = 'tok' }
43
+ client = OpenAI::Client.new
44
+ expect(client).to respond_to(:chat) # wrapper prepended
45
+ end
46
+
47
+ # ------------------------------------------------------------------
48
+ it 'executes user‑registered adapter via register_adapter' do
49
+ require 'prompt_warden'
50
+ called = false
51
+
52
+ PromptWarden.configure do |c|
53
+ c.project_token = 'tok'
54
+ c.register_adapter(:fake_sdk) { called = true }
55
+ end
56
+
57
+ # Fake gem appears later in runtime
58
+ Gem.loaded_specs['fake_sdk'] = Gem::Specification.new('fake_sdk')
59
+ PromptWarden.configuration.run_pending_adapters
60
+
61
+ expect(called).to be true
62
+ ensure
63
+ Gem.loaded_specs.delete('fake_sdk')
64
+ end
65
+ end
@@ -0,0 +1,137 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../lib/prompt_warden/instrumentation/anthropic'
4
+
5
+ RSpec.describe 'PromptWarden Anthropic 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' do
15
+ client = Object.new
16
+ client.extend(PromptWarden::Instrumentation::Anthropic)
17
+ # Call the instrumentation method directly
18
+ client.send(:_pw_wrap, :complete, {
19
+ prompt: 'hi',
20
+ model: 'claude-3-sonnet-20240229',
21
+ max_tokens: 1000
22
+ }) do
23
+ { 'completion' => 'anthropic reply' }
24
+ end
25
+ expect(PromptWarden).to have_received(:record).with(hash_including(
26
+ prompt: 'hi',
27
+ model: 'claude-3-sonnet-20240229',
28
+ cost_usd: 0.005,
29
+ status: 'ok',
30
+ alerts: []
31
+ ))
32
+ end
33
+
34
+ it 'includes alerts when policy alerts are detected' do
35
+ alerts = [{ type: 'regex', rule: '/confidential/i', level: 'warn' }]
36
+ allow(PromptWarden::Policy.instance).to receive(:check_alerts).and_return(alerts)
37
+ client = Object.new
38
+ client.extend(PromptWarden::Instrumentation::Anthropic)
39
+ # Call the instrumentation method directly
40
+ client.send(:_pw_wrap, :complete, {
41
+ prompt: 'This is confidential information',
42
+ model: 'claude-3-sonnet-20240229'
43
+ }) do
44
+ { 'completion' => 'anthropic reply' }
45
+ end
46
+ expect(PromptWarden).to have_received(:record).with(hash_including(
47
+ prompt: 'This is confidential information',
48
+ model: 'claude-3-sonnet-20240229',
49
+ status: 'ok',
50
+ alerts: alerts
51
+ ))
52
+ end
53
+
54
+ it 'records failed status on error with alerts' do
55
+ alerts = [{ type: 'regex', rule: '/ETA/i', level: 'warn' }]
56
+ allow(PromptWarden::Policy.instance).to receive(:check_alerts).and_return(alerts)
57
+ client = Object.new
58
+ client.extend(PromptWarden::Instrumentation::Anthropic)
59
+ expect {
60
+ client.send(:_pw_wrap, :complete, {
61
+ prompt: 'What is the ETA?',
62
+ model: 'claude-3-sonnet-20240229'
63
+ }) do
64
+ raise 'test error'
65
+ end
66
+ }.to raise_error('test error')
67
+ expect(PromptWarden).to have_received(:record).with(hash_including(
68
+ prompt: 'What is the ETA?',
69
+ model: 'claude-3-sonnet-20240229',
70
+ status: 'failed',
71
+ alerts: alerts
72
+ ))
73
+ end
74
+
75
+ it 'handles messages format for Anthropic API' do
76
+ client = Object.new
77
+ client.extend(PromptWarden::Instrumentation::Anthropic)
78
+
79
+ client.send(:_pw_wrap, :messages, {
80
+ messages: [
81
+ { role: 'user', content: 'Hello' },
82
+ { role: 'assistant', content: 'Hi there' },
83
+ { role: 'user', content: 'How are you?' }
84
+ ],
85
+ model: 'claude-3-sonnet-20240229'
86
+ }) do
87
+ {
88
+ 'content' => [{ 'type' => 'text', 'text' => 'anthropic reply' }],
89
+ 'usage' => { 'output_tokens' => 30 }
90
+ }
91
+ end
92
+
93
+ expect(PromptWarden).to have_received(:record).with(hash_including(
94
+ prompt: "Hello\nHi there\nHow are you?",
95
+ model: 'claude-3-sonnet-20240229',
96
+ status: 'ok',
97
+ alerts: []
98
+ ))
99
+ end
100
+
101
+ it 'uses enhanced cost calculation for estimates and actual costs' do
102
+ client = Object.new
103
+ client.extend(PromptWarden::Instrumentation::Anthropic)
104
+
105
+ # Mock cost calculation calls
106
+ allow(PromptWarden).to receive(:calculate_cost).with(
107
+ prompt: 'hi',
108
+ model: 'claude-3-sonnet-20240229'
109
+ ).and_return(0.003) # Estimate
110
+
111
+ allow(PromptWarden).to receive(:calculate_cost).with(
112
+ prompt: 'hi',
113
+ model: 'claude-3-sonnet-20240229',
114
+ response_tokens: 30
115
+ ).and_return(0.005) # Actual cost
116
+
117
+ client.send(:_pw_wrap, :complete, {
118
+ prompt: 'hi',
119
+ model: 'claude-3-sonnet-20240229'
120
+ }) do
121
+ {
122
+ 'completion' => 'anthropic reply',
123
+ 'usage' => { 'output_tokens' => 30 }
124
+ }
125
+ end
126
+
127
+ expect(PromptWarden).to have_received(:calculate_cost).with(
128
+ prompt: 'hi',
129
+ model: 'claude-3-sonnet-20240229'
130
+ )
131
+ expect(PromptWarden).to have_received(:calculate_cost).with(
132
+ prompt: 'hi',
133
+ model: 'claude-3-sonnet-20240229',
134
+ response_tokens: 30
135
+ )
136
+ end
137
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'webmock/rspec'
4
+ require 'timecop'
5
+
6
+ RSpec.describe PromptWarden::Buffer do
7
+ let(:config) do
8
+ PromptWarden::Configuration.new.tap do |c|
9
+ c.project_token = 'test'
10
+ c.flush_interval = 10 # long, we’ll call flush! manually
11
+ c.batch_bytes = 1_000
12
+ end
13
+ end
14
+
15
+ before { PromptWarden.reset! } # ensure fresh singleton
16
+
17
+ # spec/buffer_spec.rb fragment
18
+ it 'enqueues events and flushes when batch_bytes exceeded' do
19
+ uploader = instance_double(PromptWarden::Uploader, enqueue: true)
20
+ allow(PromptWarden::Uploader).to receive(:instance).and_return(uploader)
21
+
22
+ config.batch_bytes = 1_000 # raise threshold
23
+ buffer = described_class.new(config)
24
+
25
+ 3.times { buffer.push(PromptWarden::Event.new(prompt: 'hi').to_h) }
26
+
27
+ buffer.flush! # ensure final flush for deterministic spec
28
+
29
+ expect(uploader).to have_received(:enqueue).once
30
+ end
31
+
32
+ it 'timer flushes automatically after interval' do
33
+ uploader = instance_double(PromptWarden::Uploader, enqueue: true)
34
+ allow(PromptWarden::Uploader).to receive(:instance).and_return(uploader)
35
+
36
+ # use very small interval so we can wait for real time
37
+ config.flush_interval = 0.05
38
+ buffer = described_class.new(config)
39
+ buffer.push(PromptWarden::Event.new(prompt: 'auto').to_h)
40
+
41
+ sleep 0.1 # two ticks of interval
42
+ expect(uploader).to have_received(:enqueue).at_least(:once)
43
+ end
44
+ end