coolhand 0.1.5 → 0.2.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/.rubocop.yml +2 -1
- data/CHANGELOG.md +46 -0
- data/README.md +29 -30
- data/docs/anthropic.md +518 -0
- data/lib/coolhand/ruby/anthropic_interceptor.rb +300 -0
- data/lib/coolhand/ruby/api_service.rb +17 -2
- data/lib/coolhand/ruby/base_interceptor.rb +148 -0
- data/lib/coolhand/ruby/faraday_interceptor.rb +129 -0
- data/lib/coolhand/ruby/version.rb +1 -1
- data/lib/coolhand/ruby.rb +38 -7
- metadata +12 -7
- data/coolhand-ruby.gemspec +0 -42
- data/lib/coolhand/ruby/interceptor.rb +0 -119
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: coolhand
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Michael Carroll
|
|
@@ -9,10 +9,12 @@ authors:
|
|
|
9
9
|
autorequire:
|
|
10
10
|
bindir: exe
|
|
11
11
|
cert_chain: []
|
|
12
|
-
date: 2025-12-
|
|
12
|
+
date: 2025-12-17 00:00:00.000000000 Z
|
|
13
13
|
dependencies: []
|
|
14
|
-
description:
|
|
15
|
-
|
|
14
|
+
description: Automatically intercept and log LLM requests from Ruby applications.
|
|
15
|
+
Supports OpenAI, official Anthropic gem, ruby-anthropic gem, and other Faraday-based
|
|
16
|
+
libraries. Features dual interceptor architecture, streaming support, thread-safe
|
|
17
|
+
operation, and automatic duplicate request prevention.
|
|
16
18
|
email:
|
|
17
19
|
- mc@coolhandlabs.com
|
|
18
20
|
executables: []
|
|
@@ -27,15 +29,17 @@ files:
|
|
|
27
29
|
- LICENSE
|
|
28
30
|
- README.md
|
|
29
31
|
- Rakefile
|
|
30
|
-
-
|
|
32
|
+
- docs/anthropic.md
|
|
31
33
|
- docs/elevenlabs.md
|
|
32
34
|
- lib/coolhand.rb
|
|
33
35
|
- lib/coolhand/ruby.rb
|
|
36
|
+
- lib/coolhand/ruby/anthropic_interceptor.rb
|
|
34
37
|
- lib/coolhand/ruby/api_service.rb
|
|
38
|
+
- lib/coolhand/ruby/base_interceptor.rb
|
|
35
39
|
- lib/coolhand/ruby/collector.rb
|
|
36
40
|
- lib/coolhand/ruby/configuration.rb
|
|
41
|
+
- lib/coolhand/ruby/faraday_interceptor.rb
|
|
37
42
|
- lib/coolhand/ruby/feedback_service.rb
|
|
38
|
-
- lib/coolhand/ruby/interceptor.rb
|
|
39
43
|
- lib/coolhand/ruby/logger_service.rb
|
|
40
44
|
- lib/coolhand/ruby/version.rb
|
|
41
45
|
- sig/coolhand/ruby.rbs
|
|
@@ -66,5 +70,6 @@ requirements: []
|
|
|
66
70
|
rubygems_version: 3.3.26
|
|
67
71
|
signing_key:
|
|
68
72
|
specification_version: 4
|
|
69
|
-
summary:
|
|
73
|
+
summary: Monitor and log LLM API calls from OpenAI, Anthropic, and other providers
|
|
74
|
+
to Coolhand analytics.
|
|
70
75
|
test_files: []
|
data/coolhand-ruby.gemspec
DELETED
|
@@ -1,42 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require_relative "lib/coolhand/ruby/version"
|
|
4
|
-
|
|
5
|
-
Gem::Specification.new do |spec|
|
|
6
|
-
spec.name = "coolhand"
|
|
7
|
-
spec.version = Coolhand::Ruby::VERSION
|
|
8
|
-
spec.authors = ["Michael Carroll", "Yaroslav Malyk"]
|
|
9
|
-
spec.email = ["mc@coolhandlabs.com"]
|
|
10
|
-
|
|
11
|
-
spec.summary = "Intercepts and logs OpenAI API calls from a Ruby application."
|
|
12
|
-
spec.description = "A Ruby gem to automatically monitor and log external LLM requests. It patches Net::HTTP " \
|
|
13
|
-
"to capture request and response data."
|
|
14
|
-
spec.homepage = "https://coolhandlabs.com/"
|
|
15
|
-
spec.license = "Apache-2.0"
|
|
16
|
-
spec.required_ruby_version = ">= 3.0.0"
|
|
17
|
-
|
|
18
|
-
spec.metadata["allowed_push_host"] = "https://rubygems.org"
|
|
19
|
-
|
|
20
|
-
spec.metadata["homepage_uri"] = spec.homepage
|
|
21
|
-
spec.metadata["source_code_uri"] = "https://github.com/Coolhand-Labs/coolhand-ruby"
|
|
22
|
-
spec.metadata["changelog_uri"] = "https://github.com/Coolhand-Labs/coolhand-ruby"
|
|
23
|
-
|
|
24
|
-
# Specify which files should be added to the gem when it is released.
|
|
25
|
-
# The `git ls-files -z` loads the files in the RubyGem that have been added into git.
|
|
26
|
-
spec.files = Dir.chdir(__dir__) do
|
|
27
|
-
`git ls-files -z`.split("\x0").reject do |f|
|
|
28
|
-
(File.expand_path(f) == __FILE__) ||
|
|
29
|
-
f.start_with?(*%w[bin/ test/ spec/ features/ .git appveyor Gemfile])
|
|
30
|
-
end
|
|
31
|
-
end
|
|
32
|
-
spec.bindir = "exe"
|
|
33
|
-
spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
|
|
34
|
-
spec.require_paths = ["lib"]
|
|
35
|
-
|
|
36
|
-
# Uncomment to register a new dependency of your gem
|
|
37
|
-
# spec.add_dependency "example-gem", "~> 1.0"
|
|
38
|
-
|
|
39
|
-
# For more information and examples about making a new gem, check out our
|
|
40
|
-
# guide at: https://bundler.io/guides/creating_gem.html
|
|
41
|
-
spec.metadata["rubygems_mfa_required"] = "true"
|
|
42
|
-
end
|
|
@@ -1,119 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Coolhand
|
|
4
|
-
class Interceptor < Faraday::Middleware
|
|
5
|
-
ORIGINAL_METHOD_ALIAS = :coolhand_original_initialize
|
|
6
|
-
|
|
7
|
-
def self.patch!
|
|
8
|
-
return if @patched
|
|
9
|
-
|
|
10
|
-
@patched = true
|
|
11
|
-
Coolhand.log "📡 Monitoring outbound requests ..."
|
|
12
|
-
|
|
13
|
-
# Use prepend instead of alias_method to avoid conflicts with other gems
|
|
14
|
-
Faraday::Connection.prepend(Module.new do
|
|
15
|
-
def initialize(url = nil, options = nil, &block)
|
|
16
|
-
super
|
|
17
|
-
|
|
18
|
-
# Only add interceptor if it's not already present
|
|
19
|
-
use Coolhand::Interceptor unless @builder.handlers.any? { |h| h.klass == Coolhand::Interceptor }
|
|
20
|
-
end
|
|
21
|
-
end)
|
|
22
|
-
|
|
23
|
-
Coolhand.log "🔧 Setting up monitoring for Faraday ..."
|
|
24
|
-
end
|
|
25
|
-
|
|
26
|
-
def self.unpatch!
|
|
27
|
-
# NOTE: With prepend, there's no clean way to unpatch
|
|
28
|
-
# We'll mark it as unpatched so it can be re-patched
|
|
29
|
-
@patched = false
|
|
30
|
-
Coolhand.log "🔌 Faraday monitoring disabled ..."
|
|
31
|
-
end
|
|
32
|
-
|
|
33
|
-
def self.patched?
|
|
34
|
-
@patched
|
|
35
|
-
end
|
|
36
|
-
|
|
37
|
-
def call(env)
|
|
38
|
-
return super unless llm_api_request?(env)
|
|
39
|
-
|
|
40
|
-
Coolhand.log "🎯 INTERCEPTING OpenAI call #{env.url}"
|
|
41
|
-
|
|
42
|
-
call_data = build_call_data(env)
|
|
43
|
-
buffer = override_on_data(env)
|
|
44
|
-
|
|
45
|
-
process_complete_callback(env, buffer, call_data)
|
|
46
|
-
end
|
|
47
|
-
|
|
48
|
-
private
|
|
49
|
-
|
|
50
|
-
def llm_api_request?(env)
|
|
51
|
-
Coolhand.configuration.intercept_addresses.any? do |address|
|
|
52
|
-
env.url.to_s.include?(address)
|
|
53
|
-
end
|
|
54
|
-
end
|
|
55
|
-
|
|
56
|
-
def build_call_data(env)
|
|
57
|
-
{
|
|
58
|
-
id: SecureRandom.uuid,
|
|
59
|
-
timestamp: DateTime.now,
|
|
60
|
-
method: env.method,
|
|
61
|
-
url: env.url.to_s,
|
|
62
|
-
headers: sanitize_headers(env.request_headers),
|
|
63
|
-
request_body: parse_json(env.request_body),
|
|
64
|
-
response_body: nil,
|
|
65
|
-
response_headers: nil,
|
|
66
|
-
status_code: nil
|
|
67
|
-
}
|
|
68
|
-
end
|
|
69
|
-
|
|
70
|
-
def override_on_data(env)
|
|
71
|
-
buffer = +""
|
|
72
|
-
original_on_data = env.request.on_data
|
|
73
|
-
env.request.on_data = proc do |chunk, overall_received_bytes|
|
|
74
|
-
buffer << chunk
|
|
75
|
-
|
|
76
|
-
original_on_data&.call(chunk, overall_received_bytes)
|
|
77
|
-
end
|
|
78
|
-
|
|
79
|
-
buffer
|
|
80
|
-
end
|
|
81
|
-
|
|
82
|
-
def process_complete_callback(env, buffer, call_data)
|
|
83
|
-
@app.call(env).on_complete do |response_env|
|
|
84
|
-
if buffer.empty?
|
|
85
|
-
body = response_env.body
|
|
86
|
-
else
|
|
87
|
-
body = buffer
|
|
88
|
-
response_env.body = body
|
|
89
|
-
end
|
|
90
|
-
|
|
91
|
-
call_data[:response_body] = parse_json(body)
|
|
92
|
-
call_data[:response_headers] = sanitize_headers(response_env.request_headers)
|
|
93
|
-
call_data[:status_code] = response_env.status
|
|
94
|
-
|
|
95
|
-
Thread.new { Coolhand.logger_service.log_to_api(call_data) }
|
|
96
|
-
end
|
|
97
|
-
end
|
|
98
|
-
|
|
99
|
-
def parse_json(string)
|
|
100
|
-
JSON.parse(string)
|
|
101
|
-
rescue JSON::ParserError, TypeError
|
|
102
|
-
string
|
|
103
|
-
end
|
|
104
|
-
|
|
105
|
-
def sanitize_headers(headers)
|
|
106
|
-
sanitized = headers.transform_keys(&:to_s).dup
|
|
107
|
-
|
|
108
|
-
if sanitized["Authorization"]
|
|
109
|
-
sanitized["Authorization"] = sanitized["Authorization"].gsub(/Bearer .+/, "Bearer [REDACTED]")
|
|
110
|
-
end
|
|
111
|
-
|
|
112
|
-
%w[openai-api-key api-key].each do |key|
|
|
113
|
-
sanitized[key] = "[REDACTED]" if sanitized[key]
|
|
114
|
-
end
|
|
115
|
-
|
|
116
|
-
sanitized
|
|
117
|
-
end
|
|
118
|
-
end
|
|
119
|
-
end
|