coolhand 0.8.0 → 0.10.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/CHANGELOG.md +28 -0
- data/README.md +46 -2
- data/Rakefile +7 -0
- data/docs/declared_workloads.md +156 -0
- data/docs/help-center.md +120 -0
- data/docs/openai.md +4 -0
- data/lib/coolhand/api_service.rb +5 -1
- data/lib/coolhand/base_interceptor.rb +3 -1
- data/lib/coolhand/configuration.rb +14 -1
- data/lib/coolhand/declarable.rb +178 -0
- data/lib/coolhand/declared_context.rb +106 -0
- data/lib/coolhand/log_service.rb +1 -7
- data/lib/coolhand/logger_service.rb +11 -19
- data/lib/coolhand/net_http_interceptor.rb +3 -2
- data/lib/coolhand/open_ai/webhook_validator.rb +27 -15
- data/lib/coolhand/public_content_service.rb +69 -0
- data/lib/coolhand/rails_auto_declare.rb +55 -0
- data/lib/coolhand/railtie.rb +11 -0
- data/lib/coolhand/read_query.rb +14 -0
- data/lib/coolhand/read_requests.rb +11 -2
- data/lib/coolhand/response_warnings.rb +39 -0
- data/lib/coolhand/template_service.rb +26 -10
- data/lib/coolhand/version.rb +1 -1
- data/lib/coolhand/workload_service.rb +12 -8
- data/lib/coolhand/write_requests.rb +14 -0
- data/lib/coolhand.rb +41 -12
- metadata +10 -2
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Coolhand
|
|
4
|
+
# Declares the workload/template of the LLM calls a class makes, once, on the class, so passive
|
|
5
|
+
# capture picks them up without a `Coolhand.with_context` block at every call site. Wrapped methods
|
|
6
|
+
# run inside `with_context`, so nesting and precedence are exactly those documented in
|
|
7
|
+
# docs/declared_workloads.md. Settings are inherited by subclasses.
|
|
8
|
+
#
|
|
9
|
+
# class ApplicationService
|
|
10
|
+
# include Coolhand::Declarable
|
|
11
|
+
# coolhand_workload reporter: "my-app"
|
|
12
|
+
# coolhand_declare_all
|
|
13
|
+
# end
|
|
14
|
+
#
|
|
15
|
+
# class Parser < ApplicationService
|
|
16
|
+
# def call = client.chat(...) # workload "Parser#call", template "Parser#call"
|
|
17
|
+
# end
|
|
18
|
+
module Declarable
|
|
19
|
+
def self.included(base)
|
|
20
|
+
raise ArgumentError, "Coolhand::Declarable can only be included in a class" unless base.is_a?(Class)
|
|
21
|
+
|
|
22
|
+
base.extend(ClassMethods)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Never an LLM call site, and wrapping them would tax every call or declare a junk workload.
|
|
26
|
+
SKIPPED_METHODS = %i[method_missing respond_to? respond_to_missing? == === eql? equal? hash to_s inspect
|
|
27
|
+
to_str to_ary to_proc coerce <=>].freeze
|
|
28
|
+
ATTRIBUTE_MACROS = %w[attr attr_reader attr_writer attr_accessor].freeze
|
|
29
|
+
private_constant :SKIPPED_METHODS, :ATTRIBUTE_MACROS
|
|
30
|
+
|
|
31
|
+
module ClassMethods
|
|
32
|
+
# Class-level defaults, inherited field by field (the nearest class that sets a field wins). With
|
|
33
|
+
# no `key`, each wrapped method declares its own workload, "ClassName#method"; a `key` makes every
|
|
34
|
+
# method share one workload. The class name is the runtime class, so an inherited method declares
|
|
35
|
+
# under the subclass.
|
|
36
|
+
def coolhand_workload(key: nil, name: nil, description: nil, reporter: nil)
|
|
37
|
+
workload = DeclaredContext.normalize_part(:workload, { key: key, name: name, description: description }.compact,
|
|
38
|
+
DeclaredContext::WORKLOAD_FIELDS)
|
|
39
|
+
@coolhand_settings = { workload: workload, reporter: DeclaredContext.normalize_reporter(reporter) }.compact
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Wraps the named instance methods (now, or when they are defined later). `template:` is a key or a
|
|
43
|
+
# `{ key:, name: }` Hash; by default the template key is "ClassName#method".
|
|
44
|
+
def coolhand_declare(*names, template: nil)
|
|
45
|
+
# Checked here, at class definition, so a bad value never surfaces inside the host's method.
|
|
46
|
+
template = DeclaredContext.normalize_part(:template, template.is_a?(String) ? { key: template } : template,
|
|
47
|
+
DeclaredContext::TEMPLATE_FIELDS)
|
|
48
|
+
names.flatten.each do |name|
|
|
49
|
+
name = name.to_sym
|
|
50
|
+
(@coolhand_declared ||= {})[name] = { template: template }
|
|
51
|
+
coolhand_wrap(name) if method_defined?(name) || private_method_defined?(name)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Wraps every public instance method defined in this class and its subclasses after this call, so
|
|
56
|
+
# put it at the top of the class body. Private and protected methods, attribute readers/writers
|
|
57
|
+
# and object-protocol methods (see SKIPPED_METHODS) are left alone; `coolhand_declare` wraps one of
|
|
58
|
+
# those if you name it.
|
|
59
|
+
def coolhand_declare_all
|
|
60
|
+
@coolhand_declare_all = true
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# @api private
|
|
64
|
+
def coolhand_identity(method_name)
|
|
65
|
+
key = "#{name}##{method_name}"
|
|
66
|
+
template = coolhand_template_for(method_name)
|
|
67
|
+
{
|
|
68
|
+
reporter: coolhand_setting(:reporter),
|
|
69
|
+
workload: { key: key }.merge(coolhand_setting(:workload) || {}),
|
|
70
|
+
template: template || { key: key }
|
|
71
|
+
}
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# @api private
|
|
75
|
+
def coolhand_declare_call?(method_name)
|
|
76
|
+
return false if name.nil? || !Coolhand.configuration.enabled
|
|
77
|
+
return false unless DeclaredContext.valid_key?("#{name}##{method_name}")
|
|
78
|
+
|
|
79
|
+
public_method_defined?(method_name) || !coolhand_declaration(method_name).nil?
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# @api private
|
|
83
|
+
def coolhand_own_state
|
|
84
|
+
[@coolhand_settings, @coolhand_declared, @coolhand_declare_all]
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
private
|
|
88
|
+
|
|
89
|
+
def method_added(method_name)
|
|
90
|
+
super
|
|
91
|
+
coolhand_wrap(method_name) if !@coolhand_wrapping && coolhand_wrap?(method_name)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def coolhand_wrap?(method_name)
|
|
95
|
+
return true if coolhand_declaration(method_name)
|
|
96
|
+
|
|
97
|
+
public_method_defined?(method_name) && !SKIPPED_METHODS.include?(method_name) &&
|
|
98
|
+
coolhand_declare_all? && !attribute_definition?
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# True while `attr_reader`/`attr_writer`/`attr_accessor` is what is defining the method. Read from
|
|
102
|
+
# the stack rather than by overriding those macros, which would lose the class body's
|
|
103
|
+
# `private`/`protected` default visibility. A window of frames, not one fixed depth, so a
|
|
104
|
+
# `method_added` override that calls `super` does not hide the macro.
|
|
105
|
+
def attribute_definition?
|
|
106
|
+
caller_locations(1, 8)&.any? { |frame| ATTRIBUTE_MACROS.include?(frame.base_label) } || false
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def coolhand_declare_all?
|
|
110
|
+
coolhand_each_class { |klass| return true if klass.coolhand_own_state[2] }
|
|
111
|
+
false
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Redefines the method in the same class, rather than prepending a wrapper, so a later
|
|
115
|
+
# `private :name` (or `private def name`) applies to the wrapper like it would to the original.
|
|
116
|
+
def coolhand_wrap(method_name)
|
|
117
|
+
original = instance_method(method_name)
|
|
118
|
+
wrappers = (@coolhand_wrappers ||= {})
|
|
119
|
+
return if wrappers[method_name] == original
|
|
120
|
+
|
|
121
|
+
visibility = coolhand_visibility(method_name)
|
|
122
|
+
@coolhand_wrapping = true
|
|
123
|
+
define_method(method_name) do |*args, **kwargs, &block|
|
|
124
|
+
klass = self.class
|
|
125
|
+
next original.bind_call(self, *args, **kwargs, &block) unless klass.coolhand_declare_call?(method_name)
|
|
126
|
+
|
|
127
|
+
Coolhand.with_context(**klass.coolhand_identity(method_name)) do
|
|
128
|
+
original.bind_call(self, *args, **kwargs, &block)
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
send(visibility, method_name)
|
|
132
|
+
wrappers[method_name] = instance_method(method_name)
|
|
133
|
+
ensure
|
|
134
|
+
@coolhand_wrapping = false
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Includes inherited methods: wrapping one a parent made private must not make it public here.
|
|
138
|
+
def coolhand_visibility(method_name)
|
|
139
|
+
return :private if private_method_defined?(method_name)
|
|
140
|
+
return :protected if protected_method_defined?(method_name)
|
|
141
|
+
|
|
142
|
+
:public
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# Yields this class and its superclasses that include Declarable, nearest first. A loop rather
|
|
146
|
+
# than a collected Array, since the lookups below run on every wrapped call.
|
|
147
|
+
def coolhand_each_class
|
|
148
|
+
klass = self
|
|
149
|
+
while klass.respond_to?(:coolhand_own_state)
|
|
150
|
+
yield klass
|
|
151
|
+
klass = klass.superclass
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def coolhand_declaration(method_name)
|
|
156
|
+
coolhand_nearest(1, method_name)
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def coolhand_template_for(method_name)
|
|
160
|
+
coolhand_declaration(method_name)&.fetch(:template)
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def coolhand_setting(field)
|
|
164
|
+
coolhand_nearest(0, field)
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# The value for `key` in the nearest class whose own state (slot `slot` of `coolhand_own_state`)
|
|
168
|
+
# sets it, or nil.
|
|
169
|
+
def coolhand_nearest(slot, key)
|
|
170
|
+
coolhand_each_class do |klass|
|
|
171
|
+
state = klass.coolhand_own_state[slot]
|
|
172
|
+
return state[key] if state&.key?(key)
|
|
173
|
+
end
|
|
174
|
+
nil
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
end
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Coolhand
|
|
4
|
+
# Ambient workload/template identity for captured calls, set with {Coolhand.with_context}. The
|
|
5
|
+
# interceptor snapshots {current} on the calling thread before a log is queued, because the
|
|
6
|
+
# background log worker does not inherit thread-locals. See docs/declared_workloads.md.
|
|
7
|
+
module DeclaredContext
|
|
8
|
+
THREAD_KEY = :coolhand_declared_context
|
|
9
|
+
|
|
10
|
+
# The fields the server accepts under `declared.workload` / `declared.template`; anything else
|
|
11
|
+
# is dropped here rather than sent.
|
|
12
|
+
WORKLOAD_FIELDS = %i[id key name description].freeze
|
|
13
|
+
TEMPLATE_FIELDS = %i[id key name].freeze
|
|
14
|
+
|
|
15
|
+
module_function
|
|
16
|
+
|
|
17
|
+
# A frozen Hash, or nil when no context is active.
|
|
18
|
+
def current
|
|
19
|
+
Thread.current[THREAD_KEY]
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def clear!
|
|
23
|
+
Thread.current[THREAD_KEY] = nil
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Each field is replaced wholesale by the innermost block that sets it. Hashes are not merged
|
|
27
|
+
# key by key: `{ id: x }` merged with `{ key: y }` would declare both, and the server prefers `id`.
|
|
28
|
+
# A template belongs to a workload, so a block that declares a workload without a template does
|
|
29
|
+
# not inherit the outer one; it would pair the new workload with a template from another.
|
|
30
|
+
def with(reporter: nil, workload: nil, template: nil)
|
|
31
|
+
previous = current
|
|
32
|
+
declared = {
|
|
33
|
+
reporter: normalize_reporter(reporter),
|
|
34
|
+
workload: normalize_part(:workload, workload, WORKLOAD_FIELDS),
|
|
35
|
+
template: normalize_part(:template, template, TEMPLATE_FIELDS)
|
|
36
|
+
}.compact
|
|
37
|
+
inherited = previous || {}
|
|
38
|
+
inherited = inherited.except(:template) if declared.key?(:workload) && !declared.key?(:template)
|
|
39
|
+
merged = inherited.merge(declared)
|
|
40
|
+
ensure_template_has_workload!(merged)
|
|
41
|
+
Thread.current[THREAD_KEY] = merged.empty? ? nil : merged.freeze
|
|
42
|
+
yield
|
|
43
|
+
ensure
|
|
44
|
+
Thread.current[THREAD_KEY] = previous
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def normalize_reporter(reporter)
|
|
48
|
+
return nil if reporter.nil?
|
|
49
|
+
|
|
50
|
+
reporter.to_s
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Mirrors the server's key rules: at most 255 characters, no control or invisible characters, no
|
|
54
|
+
# leading or trailing whitespace, and no leading, trailing or doubled "/". The server answers a
|
|
55
|
+
# bad key with a 422 that rejects the whole log, so it is checked here, before the call is made.
|
|
56
|
+
MAX_KEY_LENGTH = 255
|
|
57
|
+
KEY_FORMAT = %r{
|
|
58
|
+
\A(?!/)(?!.*//)
|
|
59
|
+
[^\p{Cntrl}\p{Cf}[:space:]]
|
|
60
|
+
(?:[^\p{Cntrl}\p{Cf}]*[^\p{Cntrl}\p{Cf}[:space:]])?
|
|
61
|
+
(?<!/)\z
|
|
62
|
+
}x
|
|
63
|
+
|
|
64
|
+
def valid_key?(key)
|
|
65
|
+
key.is_a?(String) && key.length <= MAX_KEY_LENGTH && KEY_FORMAT.match?(key)
|
|
66
|
+
rescue ArgumentError, EncodingError
|
|
67
|
+
false # a string with a broken or incompatible encoding is not a valid key
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# A template given by key, with no workload to create it in, is a 422 that drops the log. A
|
|
71
|
+
# template `id` refers to an existing record and needs no workload.
|
|
72
|
+
def ensure_template_has_workload!(declared)
|
|
73
|
+
template = declared[:template]
|
|
74
|
+
return if template.nil? || template.key?(:id) || declared.key?(:workload)
|
|
75
|
+
|
|
76
|
+
raise ArgumentError, "template with a :key needs a workload; declare one in this or an enclosing block"
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Validated up front: the server answers a non-object `workload`/`template` with a 422 for the
|
|
80
|
+
# whole log, which would drop the call from Coolhand rather than just its identity.
|
|
81
|
+
def normalize_part(name, value, fields)
|
|
82
|
+
return nil if value.nil?
|
|
83
|
+
raise ArgumentError, "#{name} must be a Hash, got #{value.class}" unless value.is_a?(Hash)
|
|
84
|
+
|
|
85
|
+
part = value.each_with_object({}) do |(k, v), acc|
|
|
86
|
+
field = k.respond_to?(:to_sym) ? k.to_sym : nil
|
|
87
|
+
next unless fields.include?(field) && !v.nil?
|
|
88
|
+
# A blank id refers to nothing and would 422 the log, so it counts as not given.
|
|
89
|
+
next if field == :id && v.to_s.strip.empty?
|
|
90
|
+
|
|
91
|
+
acc[field] = v.to_s.dup.freeze
|
|
92
|
+
end
|
|
93
|
+
return nil if part.empty?
|
|
94
|
+
|
|
95
|
+
if part.key?(:key) && !valid_key?(part[:key])
|
|
96
|
+
raise ArgumentError, "#{name} key must be at most #{MAX_KEY_LENGTH} printable characters with no " \
|
|
97
|
+
"surrounding whitespace and no leading, trailing or doubled \"/\""
|
|
98
|
+
end
|
|
99
|
+
unless part.key?(:id) || part.key?(:key)
|
|
100
|
+
raise ArgumentError, "#{name} needs an :id or a :key, got #{value.keys.inspect}"
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
part.freeze
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
data/lib/coolhand/log_service.rb
CHANGED
|
@@ -47,13 +47,7 @@ module Coolhand
|
|
|
47
47
|
per: per
|
|
48
48
|
}.compact
|
|
49
49
|
|
|
50
|
-
logs,
|
|
51
|
-
raise Error, "#{ERROR_NOUN} response was not a JSON array" unless logs.is_a?(Array)
|
|
52
|
-
|
|
53
|
-
LogSearchResult.new(
|
|
54
|
-
logs: logs,
|
|
55
|
-
pagination: Pagination.from_headers(response, items: logs, page: page, per: per)
|
|
56
|
-
).freeze
|
|
50
|
+
fetch_list(query, LogSearchResult, :logs, page: page, per: per)
|
|
57
51
|
end
|
|
58
52
|
|
|
59
53
|
# The full log, with `cost` and `cost_breakdown`. Both are nil when the log has no tokens or its
|
|
@@ -15,24 +15,8 @@ module Coolhand
|
|
|
15
15
|
# Helper method for forwarding webhook data to Coolhand
|
|
16
16
|
def forward_webhook(webhook_body:, source:, event_type: nil, headers: {}, **options)
|
|
17
17
|
# Validate required parameters
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
if Coolhand.configuration.silent
|
|
21
|
-
puts "COOLHAND WARNING: #{error_msg}"
|
|
22
|
-
return false
|
|
23
|
-
else
|
|
24
|
-
raise ArgumentError, error_msg
|
|
25
|
-
end
|
|
26
|
-
end
|
|
27
|
-
|
|
28
|
-
unless Coolhand.required_field?(source)
|
|
29
|
-
error_msg = "source is required and cannot be nil or empty"
|
|
30
|
-
if Coolhand.configuration.silent
|
|
31
|
-
puts "COOLHAND WARNING: #{error_msg}"
|
|
32
|
-
return false
|
|
33
|
-
else
|
|
34
|
-
raise ArgumentError, error_msg
|
|
35
|
-
end
|
|
18
|
+
{ webhook_body: webhook_body, source: source }.each do |name, value|
|
|
19
|
+
return reject_webhook("#{name} is required and cannot be nil or empty") unless Coolhand.required_field?(value)
|
|
36
20
|
end
|
|
37
21
|
|
|
38
22
|
# Auto-generate required fields unless provided
|
|
@@ -40,7 +24,7 @@ module Coolhand
|
|
|
40
24
|
id: options[:id] || SecureRandom.uuid,
|
|
41
25
|
timestamp: options[:timestamp] || Time.now.utc.strftime("%Y-%m-%dT%H:%M:%S.%6NZ"),
|
|
42
26
|
method: options[:method] || "POST",
|
|
43
|
-
url: options[:url] || build_webhook_url(source, event_type),
|
|
27
|
+
url: BaseInterceptor.sanitize_url(options[:url] || build_webhook_url(source, event_type)),
|
|
44
28
|
headers: sanitize_headers(headers),
|
|
45
29
|
request_body: clean_webhook_body(webhook_body, source),
|
|
46
30
|
response_body: options[:response_body],
|
|
@@ -55,6 +39,14 @@ module Coolhand
|
|
|
55
39
|
|
|
56
40
|
private
|
|
57
41
|
|
|
42
|
+
# Silent mode warns and drops the webhook; otherwise the caller gets the error.
|
|
43
|
+
def reject_webhook(error_msg)
|
|
44
|
+
raise ArgumentError, error_msg unless Coolhand.configuration.silent
|
|
45
|
+
|
|
46
|
+
puts "COOLHAND WARNING: #{error_msg}"
|
|
47
|
+
false
|
|
48
|
+
end
|
|
49
|
+
|
|
58
50
|
def build_webhook_url(source, event_type)
|
|
59
51
|
base = "webhook://#{source}"
|
|
60
52
|
event_type ? "#{base}/#{event_type}" : base
|
|
@@ -46,13 +46,12 @@ module Coolhand
|
|
|
46
46
|
Net::HTTPResponse.prepend(ResponseInterceptor)
|
|
47
47
|
|
|
48
48
|
@patched = true
|
|
49
|
-
Coolhand.log "🔗 Net::HTTP interceptor patched"
|
|
50
49
|
rescue StandardError
|
|
51
50
|
# Roll back this call's hold — it never actually took effect, so it must not count
|
|
52
51
|
# toward the refcount or a legitimate later unpatch! would underflow against it. This
|
|
53
52
|
# branch only runs when @patched was false on entry (see `next if @patched` above), so
|
|
54
53
|
# forcing it back to false here is correct regardless of which line above raised —
|
|
55
|
-
# including a failure in the
|
|
54
|
+
# including a failure in the second prepend, after the first already succeeded.
|
|
56
55
|
@patch_count -= 1
|
|
57
56
|
@patched = false
|
|
58
57
|
raise
|
|
@@ -139,7 +138,9 @@ module Coolhand
|
|
|
139
138
|
end_time = Time.now
|
|
140
139
|
duration_ms = ((end_time - start_time) * 1000).round(2)
|
|
141
140
|
|
|
141
|
+
# Read here on the calling thread: the log worker thread has no access to our thread-locals.
|
|
142
142
|
BaseInterceptor.send_complete_request_log(
|
|
143
|
+
declared: DeclaredContext.current,
|
|
143
144
|
request_id: request_id,
|
|
144
145
|
method: req.method,
|
|
145
146
|
url: url,
|
|
@@ -7,6 +7,9 @@ require_relative "webhook_id_store"
|
|
|
7
7
|
module Coolhand
|
|
8
8
|
module OpenAi
|
|
9
9
|
class WebhookValidator
|
|
10
|
+
SIGNATURE_VERSION_PREFIX = "v1,"
|
|
11
|
+
private_constant :SIGNATURE_VERSION_PREFIX
|
|
12
|
+
|
|
10
13
|
attr_reader :request, :errors, :payload
|
|
11
14
|
|
|
12
15
|
def initialize(request, webhook_secret)
|
|
@@ -41,27 +44,25 @@ module Coolhand
|
|
|
41
44
|
def payload_valid?
|
|
42
45
|
return true if @payload
|
|
43
46
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
"not in development/test allowlist)"
|
|
47
|
-
Rails.logger.error(@errors.last)
|
|
48
|
-
false
|
|
49
|
-
else
|
|
50
|
-
Rails.logger.warn("Empty webhook payload - allowing in #{Rails.env} environment")
|
|
51
|
-
true
|
|
52
|
-
end
|
|
47
|
+
enforce_or_allow("Empty webhook payload",
|
|
48
|
+
"Empty webhook payload - allowing in #{Rails.env} environment")
|
|
53
49
|
end
|
|
54
50
|
|
|
55
51
|
def validate_in_non_production_env
|
|
52
|
+
enforce_or_allow("OpenAI webhook secret not configured",
|
|
53
|
+
"Webhook Secret is not configured - skipping signature verification in #{Rails.env}")
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Outside the development/test allowlist the webhook is rejected with `problem`; inside it the
|
|
57
|
+
# check is skipped with a warning.
|
|
58
|
+
def enforce_or_allow(problem, allow_warning)
|
|
56
59
|
if should_enforce_strict_validation?
|
|
57
|
-
@errors << "
|
|
60
|
+
@errors << "#{problem} - rejecting webhook (Rails.env=#{Rails.env.inspect} " \
|
|
58
61
|
"not in development/test allowlist)"
|
|
59
62
|
Rails.logger.error(@errors.last)
|
|
60
63
|
false
|
|
61
64
|
else
|
|
62
|
-
Rails.logger.warn(
|
|
63
|
-
"Webhook Secret is not configured - skipping signature verification in #{Rails.env}"
|
|
64
|
-
)
|
|
65
|
+
Rails.logger.warn(allow_warning)
|
|
65
66
|
true
|
|
66
67
|
end
|
|
67
68
|
end
|
|
@@ -99,8 +100,7 @@ module Coolhand
|
|
|
99
100
|
expected_signature = calculate_expected_signature(secret_bytes, signed_payload)
|
|
100
101
|
|
|
101
102
|
# An empty key (a bare "whsec_" secret) makes the HMAC forgeable by anyone, so never accept it.
|
|
102
|
-
signature_valid = !secret_bytes.empty? &&
|
|
103
|
-
secure_compare(webhook_signature[3..], expected_signature)
|
|
103
|
+
signature_valid = !secret_bytes.empty? && signature_matches?(webhook_signature, expected_signature)
|
|
104
104
|
|
|
105
105
|
unless signature_valid
|
|
106
106
|
@errors << "OpenAI webhook signature verification failed"
|
|
@@ -114,6 +114,18 @@ module Coolhand
|
|
|
114
114
|
true
|
|
115
115
|
end
|
|
116
116
|
|
|
117
|
+
# Standard Webhooks allows several space-separated signatures (e.g. during secret rotation);
|
|
118
|
+
# accept the request if any v1 entry matches. Every candidate is compared (`map`, not an
|
|
119
|
+
# `any?` block) so there is no early exit on the first match.
|
|
120
|
+
# Split as binary so a header that isn't valid UTF-8 is rejected rather than raising.
|
|
121
|
+
def signature_matches?(webhook_signature, expected_signature)
|
|
122
|
+
candidates = webhook_signature.b.split.select { |entry| entry.start_with?(SIGNATURE_VERSION_PREFIX) }
|
|
123
|
+
results = candidates.map do |entry|
|
|
124
|
+
secure_compare(entry.delete_prefix(SIGNATURE_VERSION_PREFIX), expected_signature)
|
|
125
|
+
end
|
|
126
|
+
results.any?
|
|
127
|
+
end
|
|
128
|
+
|
|
117
129
|
# Checked only after the signature is confirmed valid, so an
|
|
118
130
|
# unsigned/forged request can't poison the id-dedup store (or fail a
|
|
119
131
|
# freshness check) and DoS a later legitimate webhook with the same id.
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "api_service"
|
|
4
|
+
require_relative "pagination"
|
|
5
|
+
require_relative "read_query"
|
|
6
|
+
|
|
7
|
+
module Coolhand
|
|
8
|
+
# Rows stay plain Symbol-keyed Hashes so a field the server adds later is not dropped in transit.
|
|
9
|
+
HelpArticleSearchResult = Struct.new(:articles, :pagination, keyword_init: true)
|
|
10
|
+
|
|
11
|
+
# Read-only access to the Coolhand help center: the `public_content` endpoints (`GET /api/v2/public_content`,
|
|
12
|
+
# `/:slug` and `/categories`), the same pages served at `/help/:slug` and `/updates/:slug`. Create, update and
|
|
13
|
+
# archive are Coolhand-internal and not wrapped.
|
|
14
|
+
#
|
|
15
|
+
# Unlike every other service this one needs no API key. A configured `api_key` is sent as `X-API-Key` and lifts
|
|
16
|
+
# the anonymous limit of 30 requests per minute per IP (429 beyond); with none, no key header is sent. A key the
|
|
17
|
+
# server rejects raises a 401 {Coolhand::HttpError}; it is not retried anonymously. See docs/help-center.md.
|
|
18
|
+
class PublicContentService < ApiService
|
|
19
|
+
include ReadQuery
|
|
20
|
+
|
|
21
|
+
ERROR_NOUN = "Help center"
|
|
22
|
+
BLANK_SLUG_MESSAGE = "get_help_article: slug must be a non-empty string"
|
|
23
|
+
|
|
24
|
+
def initialize
|
|
25
|
+
super("v2/public_content")
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Relevance search with `query`, a full listing without. `category` filters to one section slug; the
|
|
29
|
+
# endpoint has no such parameter, so it is sent as `q[help_category_slug_eq]` and `query` moves to
|
|
30
|
+
# `q[full_text]` (the plain `q` string and the `q[...]` form cannot be mixed). A blank `query` or
|
|
31
|
+
# `category` counts as omitted. Returns a {HelpArticleSearchResult}; raises {Coolhand::HttpError} on a
|
|
32
|
+
# non-2xx response.
|
|
33
|
+
def search_help_articles(query: nil, category: nil, page: nil, per_page: nil)
|
|
34
|
+
params = { **search_params(query, category), page: page, per_page: per_page }.compact
|
|
35
|
+
|
|
36
|
+
fetch_list(params, HelpArticleSearchResult, :articles, page: page, per: per_page)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# One published article with its markdown `body`, `noindex` and `toc`. Raises {Coolhand::Error} before any
|
|
40
|
+
# request for a blank slug, and {Coolhand::HttpError} (404) for an unknown or unpublished one.
|
|
41
|
+
def get_help_article(slug)
|
|
42
|
+
get_json(resource_url(slug, BLANK_SLUG_MESSAGE), ERROR_NOUN)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Every section an article can be filed under, with its `url_prefix` and `allowed_tags`.
|
|
46
|
+
def list_help_categories
|
|
47
|
+
get_json(URI.parse("#{api_endpoint}/categories"), ERROR_NOUN)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
protected
|
|
51
|
+
|
|
52
|
+
def api_key_required?
|
|
53
|
+
false
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
def search_params(query, category)
|
|
59
|
+
query = nil if omitted?(query)
|
|
60
|
+
return { q: query } if omitted?(category)
|
|
61
|
+
|
|
62
|
+
{ "q[help_category_slug_eq]": category, "q[full_text]": query }
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def omitted?(value)
|
|
66
|
+
value.nil? || value.to_s.strip.empty?
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Coolhand
|
|
4
|
+
# Opt-in (`config.auto_declare`) declaration of the workload and template for LLM calls made from a
|
|
5
|
+
# Rails job or controller action, keyed "JobClass#perform" / "ControllerClass#action". Installed from
|
|
6
|
+
# {Railtie} once the app has configured Coolhand. Kept free of any Rails require: it only touches
|
|
7
|
+
# ActiveSupport and ActiveJob when a target is enabled, and only through their own load hooks.
|
|
8
|
+
module RailsAutoDeclare
|
|
9
|
+
REPORTER = "rails"
|
|
10
|
+
TARGETS = %i[active_job controller].freeze
|
|
11
|
+
|
|
12
|
+
@installed = []
|
|
13
|
+
|
|
14
|
+
module_function
|
|
15
|
+
|
|
16
|
+
def install(targets = Coolhand.configuration.auto_declare)
|
|
17
|
+
TARGETS.each do |target|
|
|
18
|
+
next unless targets.include?(target) && !@installed.include?(target)
|
|
19
|
+
|
|
20
|
+
@installed << target
|
|
21
|
+
send(:"hook_#{target}")
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def hook_active_job
|
|
26
|
+
ActiveSupport.on_load(:active_job) do
|
|
27
|
+
around_perform do |job, block|
|
|
28
|
+
Coolhand::RailsAutoDeclare.declare(job.class, "perform", &block)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def hook_controller
|
|
34
|
+
ActiveSupport.on_load(:action_controller) do
|
|
35
|
+
around_action do |controller, block|
|
|
36
|
+
Coolhand::RailsAutoDeclare.declare(controller.class, controller.action_name, &block)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Runs the block inside a declared context. Anonymous classes have no stable key, so they are left
|
|
42
|
+
# undeclared rather than named after an object id.
|
|
43
|
+
def declare(klass, method_name, &block)
|
|
44
|
+
key = "#{klass.name}##{method_name}"
|
|
45
|
+
return yield if klass.name.nil? || !Coolhand.configuration.enabled || !DeclaredContext.valid_key?(key)
|
|
46
|
+
|
|
47
|
+
Coolhand.with_context(reporter: REPORTER, workload: { key: key }, template: { key: key }, &block)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Testing-only: forget which targets were hooked.
|
|
51
|
+
def reset!
|
|
52
|
+
@installed.clear
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Coolhand
|
|
4
|
+
# Loaded by lib/coolhand.rb only when Rails is present, so the gem keeps no Rails dependency.
|
|
5
|
+
class Railtie < ::Rails::Railtie
|
|
6
|
+
# After the app's initializers, so `config.auto_declare` has been set by `Coolhand.configure`.
|
|
7
|
+
config.after_initialize do
|
|
8
|
+
Coolhand::RailsAutoDeclare.install
|
|
9
|
+
end
|
|
10
|
+
end
|
|
11
|
+
end
|
data/lib/coolhand/read_query.rb
CHANGED
|
@@ -4,6 +4,7 @@ require "date"
|
|
|
4
4
|
require "time"
|
|
5
5
|
require "uri"
|
|
6
6
|
require_relative "errors"
|
|
7
|
+
require_relative "pagination"
|
|
7
8
|
|
|
8
9
|
module Coolhand
|
|
9
10
|
# URL building shared by the read services. Everything goes through `URI.encode_www_form`, so a
|
|
@@ -17,6 +18,19 @@ module Coolhand
|
|
|
17
18
|
uri
|
|
18
19
|
end
|
|
19
20
|
|
|
21
|
+
# GETs a list endpoint and wraps the JSON array and its pagination headers in `result_class`,
|
|
22
|
+
# keyed by `rows_key` (the field the result struct names its rows by).
|
|
23
|
+
def fetch_list(query, result_class, rows_key, page:, per:)
|
|
24
|
+
noun = self.class::ERROR_NOUN
|
|
25
|
+
rows, response = get_json_with_headers(list_url(query), noun)
|
|
26
|
+
raise Error, "#{noun} response was not a JSON array" unless rows.is_a?(Array)
|
|
27
|
+
|
|
28
|
+
result_class.new(
|
|
29
|
+
rows_key => rows,
|
|
30
|
+
pagination: Pagination.from_headers(response, items: rows, page: page, per: per)
|
|
31
|
+
).freeze
|
|
32
|
+
end
|
|
33
|
+
|
|
20
34
|
def resource_url(id, blank_message, query = {})
|
|
21
35
|
raise Error, blank_message unless id.is_a?(String)
|
|
22
36
|
|
|
@@ -29,7 +29,9 @@ module Coolhand
|
|
|
29
29
|
|
|
30
30
|
# Also returns the response, for endpoints that carry pagination in headers rather than the body.
|
|
31
31
|
def get_json_with_headers(url, noun)
|
|
32
|
-
|
|
32
|
+
if api_key_required? && !Coolhand.required_field?(api_key)
|
|
33
|
+
raise Error, "#{noun} request failed: an API key is required"
|
|
34
|
+
end
|
|
33
35
|
|
|
34
36
|
response = perform_get(url, noun)
|
|
35
37
|
|
|
@@ -38,6 +40,11 @@ module Coolhand
|
|
|
38
40
|
[parse_json_body(response.body, noun), response]
|
|
39
41
|
end
|
|
40
42
|
|
|
43
|
+
# Overridden by services over public endpoints, which work anonymously and send the key only if set.
|
|
44
|
+
def api_key_required?
|
|
45
|
+
true
|
|
46
|
+
end
|
|
47
|
+
|
|
41
48
|
private
|
|
42
49
|
|
|
43
50
|
def perform_get(url, noun)
|
|
@@ -45,7 +52,9 @@ module Coolhand
|
|
|
45
52
|
http = build_http(uri)
|
|
46
53
|
|
|
47
54
|
request = Net::HTTP::Get.new(uri.request_uri)
|
|
48
|
-
|
|
55
|
+
headers = { "Accept" => "application/json" }
|
|
56
|
+
headers["X-API-Key"] = api_key if Coolhand.required_field?(api_key)
|
|
57
|
+
apply_headers(request, headers)
|
|
49
58
|
|
|
50
59
|
# Net::HTTP does not follow redirects, so a 3xx raises rather than replaying the API key at
|
|
51
60
|
# an unapproved host. without_capture is the same recursion guard send_request uses.
|