coolhand 0.7.0 → 0.8.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.
@@ -16,7 +16,9 @@ module Coolhand
16
16
 
17
17
  case batch_info["status"]
18
18
  when "completed"
19
- process_completed_batch
19
+ # Downloads two files and POSTs one log per item — run it on the background log queue so
20
+ # the webhook response isn't held up for the whole batch.
21
+ LogQueue.submit(lane: :batch) { process_completed_batch_logging_errors }
20
22
  when "failed", "expired", "cancelled"
21
23
  handle_failed_batch
22
24
  when "in_progress", "validating", "finalizing"
@@ -30,6 +32,14 @@ module Coolhand
30
32
 
31
33
  private
32
34
 
35
+ # Runs on the batch lane's worker thread, where an escaping error would only reach
36
+ # Coolhand.log (silenceable) rather than the Rails log `call` reports to.
37
+ def process_completed_batch_logging_errors
38
+ process_completed_batch
39
+ rescue StandardError => e
40
+ Rails.logger.error("[Interceptor] Failed to process OpenAI batch results for #{event_data}: #{e.message}")
41
+ end
42
+
33
43
  def process_completed_batch
34
44
  input_file_id = batch_info["input_file_id"]
35
45
  return unless input_file_id
@@ -43,8 +53,12 @@ module Coolhand
43
53
  # Download and process results
44
54
  batch_response_items = download_batch_results(output_file_id)
45
55
 
56
+ request_items_by_id = batch_request_items.each_with_object({}) do |item, index|
57
+ index[item["custom_id"]] ||= item
58
+ end
59
+
46
60
  batch_response_items.each do |response_item|
47
- request_item = batch_request_items.detect { |item| item["custom_id"] == response_item["custom_id"] }
61
+ request_item = request_items_by_id[response_item["custom_id"]]
48
62
 
49
63
  next unless request_item
50
64
 
@@ -89,33 +103,20 @@ module Coolhand
89
103
 
90
104
  def send_complete_request_log(request_id:, method:, url:, request_body:, response_body:, status_code:,
91
105
  start_time:, end_time:)
92
- timestamp = Time.at(start_time).iso8601
93
- completed_at = Time.at(end_time).iso8601
94
- duration_ms = ((end_time - start_time) * 1000).to_i
95
-
96
- request_data = {
97
- raw_request: {
98
- id: request_id,
99
- timestamp: timestamp,
100
- method: method.to_s.downcase,
101
- url: BaseInterceptor.sanitize_url(url),
102
- headers: {},
103
- request_body: request_body,
104
- response_headers: {},
105
- response_body: response_body,
106
- status_code: status_code,
107
- duration_ms: duration_ms,
108
- completed_at: completed_at,
109
- is_streaming: false
110
- }
111
- }
112
-
113
- api_service = Coolhand::ApiService.new
114
- api_service.send_llm_request_log(request_data)
115
-
116
- Coolhand.log "📤 Sent complete request/response log for #{request_id} (duration: #{duration_ms}ms)"
117
- rescue StandardError => e
118
- Coolhand.log "❌ Error sending complete request log: #{e.message}"
106
+ BaseInterceptor.send_complete_request_log(
107
+ request_id: request_id,
108
+ method: method,
109
+ url: url,
110
+ request_headers: {},
111
+ request_body: request_body,
112
+ response_headers: {},
113
+ response_body: response_body,
114
+ status_code: status_code,
115
+ start_time: Time.at(start_time),
116
+ end_time: Time.at(end_time),
117
+ duration_ms: ((end_time - start_time) * 1000).to_i,
118
+ is_streaming: false
119
+ )
119
120
  end
120
121
 
121
122
  def batch_info
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+ require_relative "api_service"
5
+ require_relative "write_requests"
6
+
7
+ module Coolhand
8
+ # Requires the client's **private** key; the public key gets a 401. Unlike the logging writes,
9
+ # every method raises {Coolhand::Error} / {Coolhand::HttpError} on failure. See docs/feedback-links.md.
10
+ class OptimizationFeedbackLinkService < ApiService
11
+ include WriteRequests
12
+
13
+ ERROR_NOUN = "Feedback link"
14
+ # The server rejects more ids than this per request with a 422.
15
+ BULK_BATCH_SIZE = 100
16
+
17
+ def initialize
18
+ super("v2/optimizations")
19
+ end
20
+
21
+ # Returns `{ id:, optimization_id:, feedback_id:, note:, created_at: }`; `id` is the link's hashid.
22
+ def link_feedback(optimization_id, feedback_id, note: nil)
23
+ validate_id!("link_feedback", "feedback_id", feedback_id)
24
+ url = links_url("link_feedback", optimization_id)
25
+ ensure_object!(request_json(:post, url, ERROR_NOUN, body: { feedback_id: feedback_id.strip, note: note }.compact))
26
+ end
27
+
28
+ # Lists over 100 ids are sent in batches; a failing batch raises and earlier ones stay applied,
29
+ # so repeating the call is safe. Returns `{ linked:, already_linked:, errored:, not_found: }` summed
30
+ # across batches.
31
+ def bulk_link_feedback(optimization_id, feedback_ids, note: nil)
32
+ unless feedback_ids.is_a?(Array) && !feedback_ids.empty?
33
+ raise Error, "bulk_link_feedback: feedback_ids must be a non-empty array of hashids"
34
+ end
35
+
36
+ feedback_ids.each { |id| validate_id!("bulk_link_feedback", "feedback_ids entries", id) }
37
+ feedback_ids = feedback_ids.map(&:strip)
38
+ url = links_url("bulk_link_feedback", optimization_id)
39
+
40
+ totals = { linked: 0, already_linked: 0, errored: 0, not_found: [] }
41
+ feedback_ids.each_slice(BULK_BATCH_SIZE) do |batch|
42
+ result = request_json(:post, url, ERROR_NOUN, body: { feedback_ids: batch, note: note }.compact)
43
+ merge_bulk_result!(totals, result)
44
+ end
45
+ totals
46
+ end
47
+
48
+ # `link_id` is the `id` returned by {#link_feedback}, not the feedback's. Returns true.
49
+ def unlink_feedback(optimization_id, link_id)
50
+ validate_id!("unlink_feedback", "link_id", link_id)
51
+ url = URI.parse("#{links_url('unlink_feedback', optimization_id)}/#{escape_path_segment(link_id.strip)}")
52
+ request_json(:delete, url, ERROR_NOUN)
53
+ true
54
+ end
55
+
56
+ private
57
+
58
+ def merge_bulk_result!(totals, result)
59
+ ensure_object!(result)
60
+
61
+ %i[linked already_linked errored].each { |key| totals[key] += result[key].to_i }
62
+ totals[:not_found].concat(Array(result[:not_found]))
63
+ end
64
+
65
+ def ensure_object!(result)
66
+ raise Error, "#{ERROR_NOUN} response was not a JSON object" unless result.is_a?(Hash)
67
+
68
+ result
69
+ end
70
+
71
+ def links_url(method_name, optimization_id)
72
+ validate_id!(method_name, "optimization_id", optimization_id)
73
+ URI.parse("#{api_endpoint}/#{escape_path_segment(optimization_id.strip)}/feedback_links")
74
+ end
75
+
76
+ # A blank id would resolve to a different route and a bare dot segment would retarget the
77
+ # request; neither 404s, so both are rejected before any request is made.
78
+ def validate_id!(method_name, label, value)
79
+ trimmed = value.is_a?(String) ? value.strip : ""
80
+ return unless trimmed.empty? || [".", ".."].include?(trimmed)
81
+
82
+ raise Error, "#{method_name}: #{label} must be a non-empty hashid"
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "date"
4
+ require "time"
5
+ require "uri"
6
+ require_relative "errors"
7
+
8
+ module Coolhand
9
+ # URL building shared by the read services. Everything goes through `URI.encode_www_form`, so a
10
+ # `+` UTC offset reaches the server as `%2B` instead of being read as a space.
11
+ module ReadQuery
12
+ protected
13
+
14
+ def list_url(query)
15
+ uri = URI.parse(api_endpoint)
16
+ uri.query = URI.encode_www_form(query) unless query.empty?
17
+ uri
18
+ end
19
+
20
+ def resource_url(id, blank_message, query = {})
21
+ raise Error, blank_message unless id.is_a?(String)
22
+
23
+ trimmed = id.strip
24
+ # A blank id resolves to the index route (bare array, not one record); a bare dot segment
25
+ # retargets the request at another path. Neither would 404, so both are rejected here.
26
+ raise Error, blank_message if trimmed.empty? || [".", ".."].include?(trimmed)
27
+
28
+ uri = URI.parse("#{api_endpoint}/#{escape_path_segment(trimmed)}")
29
+ uri.query = URI.encode_www_form(query) unless query.empty?
30
+ uri
31
+ end
32
+
33
+ # `until` is a reserved word, so a method taking an `until:` keyword cannot read it by name;
34
+ # callers pass `binding.local_variable_get(:until)`.
35
+ def window_params(since, until_)
36
+ { since: time_param(:since, since), until: time_param(:until, until_) }
37
+ end
38
+
39
+ # Accepts Time, DateTime, Date or an ISO8601 String. Strings go through untouched so the server
40
+ # stays the one validator. Raises ArgumentError before any request for anything else.
41
+ def time_param(name, value)
42
+ case value
43
+ when nil, String then value
44
+ when DateTime then utc_iso8601(value.to_time)
45
+ when Time then utc_iso8601(value)
46
+ when Date then value.iso8601
47
+ else
48
+ raise ArgumentError, "#{name} must be a Time, DateTime, Date or ISO8601 String, got #{value.class}"
49
+ end
50
+ end
51
+
52
+ private
53
+
54
+ # Whole seconds keep the plain form; a fractional instant keeps its microseconds so a window bound
55
+ # is not silently truncated.
56
+ def utc_iso8601(time)
57
+ utc = time.getutc
58
+ utc.subsec.zero? ? utc.iso8601 : utc.iso8601(6)
59
+ end
60
+ end
61
+ end
@@ -6,9 +6,10 @@ require "json"
6
6
  require_relative "errors"
7
7
 
8
8
  module Coolhand
9
- # The GET half of {ApiService}, split out to keep that class inside this repo's 200-line budget.
9
+ # The GET half of {ApiService}, plus the HTTP helpers {WriteRequests} shares, split out to keep
10
+ # that class inside this repo's 200-line budget.
10
11
  #
11
- # Reads raise where writes log-and-return-nil, on purpose: a write is instrumentation inline in
12
+ # Reads raise where the logging writes log-and-return-nil, on purpose: a write is instrumentation inline in
12
13
  # the host app's request, while a read's caller must tell a 404 from a timeout from an empty result.
13
14
  module ReadRequests
14
15
  # 60s is deliberate, not a slip: writes allow 5, but the server bounds each *statement* at 10s
@@ -32,13 +33,7 @@ module Coolhand
32
33
 
33
34
  response = perform_get(url, noun)
34
35
 
35
- unless response.is_a?(Net::HTTPSuccess)
36
- raise HttpError.new(
37
- "#{noun} request failed (#{response.code}): #{format_error_body(response.body)}",
38
- status: response.code.to_i,
39
- body: retained_error_body(response.body)
40
- )
41
- end
36
+ raise_http_error(response, noun) unless response.is_a?(Net::HTTPSuccess)
42
37
 
43
38
  [parse_json_body(response.body, noun), response]
44
39
  end
@@ -47,10 +42,7 @@ module Coolhand
47
42
 
48
43
  def perform_get(url, noun)
49
44
  uri = url.is_a?(URI::Generic) ? url : URI.parse(url.to_s)
50
- http = Net::HTTP.new(uri.host, uri.port)
51
- http.use_ssl = (uri.scheme == "https")
52
- http.open_timeout = READ_OPEN_TIMEOUT
53
- http.read_timeout = READ_TIMEOUT
45
+ http = build_http(uri)
54
46
 
55
47
  request = Net::HTTP::Get.new(uri.request_uri)
56
48
  apply_headers(request, "Accept" => "application/json", "X-API-Key" => api_key)
@@ -62,6 +54,27 @@ module Coolhand
62
54
  raise Error, "#{noun} request failed: #{e.message}"
63
55
  end
64
56
 
57
+ def build_http(uri)
58
+ http = Net::HTTP.new(uri.host, uri.port)
59
+ http.use_ssl = (uri.scheme == "https")
60
+ http.open_timeout = READ_OPEN_TIMEOUT
61
+ http.read_timeout = READ_TIMEOUT
62
+ http
63
+ end
64
+
65
+ def raise_http_error(response, noun)
66
+ raise HttpError.new(
67
+ "#{noun} request failed (#{response.code}): #{format_error_body(response.body)}",
68
+ status: response.code.to_i,
69
+ body: retained_error_body(response.body)
70
+ )
71
+ end
72
+
73
+ # Escapes to RFC 3986 unreserved, so an id carrying `/`, `?` or `#` cannot retarget the request.
74
+ def escape_path_segment(value)
75
+ URI::DEFAULT_PARSER.escape(value, /[^A-Za-z0-9\-._~]/)
76
+ end
77
+
65
78
  def retained_error_body(body)
66
79
  return body if body.nil? || body.length <= RETAINED_ERROR_BODY_LIMIT
67
80
 
@@ -1,8 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "uri"
4
3
  require_relative "api_service"
5
4
  require_relative "pagination"
5
+ require_relative "read_query"
6
6
 
7
7
  module Coolhand
8
8
  # Rows stay plain Symbol-keyed Hashes so a field the server adds later is not dropped in transit.
@@ -14,6 +14,8 @@ module Coolhand
14
14
  # Not a port of the MCP `search_templates` tool and does not agree with its `log_count`.
15
15
  # See docs/template-search.md.
16
16
  class TemplateService < ApiService
17
+ include ReadQuery
18
+
17
19
  ERROR_NOUN = "Template"
18
20
  BLANK_ID_MESSAGE = "get_template: id must be a non-empty template hashid"
19
21
 
@@ -23,13 +25,16 @@ module Coolhand
23
25
 
24
26
  # Filters, error semantics and server behaviour: docs/template-search.md.
25
27
  def search_templates(search: nil, workload_id: nil, status: nil, include_deprecated: nil,
26
- include_system: nil, page: nil, per: nil)
28
+ include_system: nil, include_metrics: nil, days_back: nil, since: nil, until: nil, page: nil, per: nil)
27
29
  query = {
28
30
  search: search,
29
31
  workload_id: workload_id,
30
32
  status: status,
31
33
  include_deprecated: include_deprecated,
32
34
  include_system: include_system,
35
+ include_metrics: include_metrics,
36
+ days_back: days_back,
37
+ **window_params(since, binding.local_variable_get(:until)),
33
38
  page: page,
34
39
  per: per
35
40
  }.compact
@@ -44,33 +49,12 @@ module Coolhand
44
49
  end
45
50
 
46
51
  # Adds `user_prompt_pattern` / `system_prompt_pattern`, which the list omits, and unlike the
47
- # list reaches deprecated and system templates by id with no opt-in flag.
48
- def get_template(id)
49
- get_json(resource_url(id), ERROR_NOUN)
50
- end
51
-
52
- private
53
-
54
- def list_url(query)
55
- uri = URI.parse(api_endpoint)
56
- uri.query = URI.encode_www_form(query) unless query.empty?
57
- uri
58
- end
59
-
60
- def resource_url(id)
61
- raise Error, BLANK_ID_MESSAGE unless id.is_a?(String)
62
-
63
- trimmed = id.strip
64
- # A blank id resolves to the index route (bare array, not one template); a bare dot segment
65
- # retargets the request at another path. Neither would 404, so both are rejected here.
66
- raise Error, BLANK_ID_MESSAGE if trimmed.empty? || [".", ".."].include?(trimmed)
67
-
68
- URI.parse("#{api_endpoint}/#{escape_path_segment(trimmed)}")
69
- end
70
-
71
- # Escapes to RFC 3986 unreserved, so an id carrying `/`, `?` or `#` cannot retarget the request.
72
- def escape_path_segment(value)
73
- URI::DEFAULT_PARSER.escape(value, /[^A-Za-z0-9\-._~]/)
52
+ # list reaches deprecated and system templates by id with no opt-in flag. Carries `metrics`
53
+ # unless `include_metrics: false`, which also skips the window validation.
54
+ def get_template(id, include_metrics: nil, days_back: nil, since: nil, until: nil)
55
+ query = { include_metrics: include_metrics, days_back: days_back,
56
+ **window_params(since, binding.local_variable_get(:until)) }.compact
57
+ get_json(resource_url(id, BLANK_ID_MESSAGE, query), ERROR_NOUN)
74
58
  end
75
59
  end
76
60
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Coolhand
4
- VERSION = "0.7.0"
4
+ VERSION = "0.8.0"
5
5
  end
@@ -31,7 +31,7 @@ module Coolhand
31
31
  when "JOB_STATE_PENDING", "JOB_STATE_RUNNING", "JOB_STATE_QUEUED"
32
32
  Rails.logger.info("[Interceptor] Vertex batch #{batch_info} still processing")
33
33
  when "JOB_STATE_SUCCEEDED"
34
- process_completed_batch(batch_results)
34
+ LogQueue.submit(lane: :batch) { process_completed_batch_logging_errors(batch_results) }
35
35
  when "JOB_STATE_FAILED"
36
36
  handle_failed_batch
37
37
  else
@@ -43,6 +43,14 @@ module Coolhand
43
43
 
44
44
  private
45
45
 
46
+ # Runs on the batch lane's worker thread, where an escaping error would only reach
47
+ # Coolhand.log (silenceable) rather than the Rails log `call` reports to.
48
+ def process_completed_batch_logging_errors(batch_results)
49
+ process_completed_batch(batch_results)
50
+ rescue StandardError => e
51
+ Rails.logger.error("[Interceptor] Failed to process Vertex batch results for #{batch_info}: #{e.message}")
52
+ end
53
+
46
54
  def process_completed_batch(batch_results)
47
55
  name = batch_info["name"].to_s.sub(%r{\A/+}, "")
48
56
  unless name.match?(VALID_NAME_PATTERN)
@@ -0,0 +1,46 @@
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
+ WorkloadSearchResult = Struct.new(:workloads, :pagination, keyword_init: true)
10
+
11
+ # Read-only. Requires the client's **private** key. There is no per-id endpoint.
12
+ # See docs/workload-search.md.
13
+ class WorkloadService < ApiService
14
+ include ReadQuery
15
+
16
+ ERROR_NOUN = "Workload"
17
+
18
+ def initialize
19
+ super("v2/workloads")
20
+ end
21
+
22
+ # Filters, windows, error semantics and server behaviour: docs/workload-search.md.
23
+ def search_workloads(search: nil, include_archived: nil, include_system: nil, include_templates: nil,
24
+ include_metrics: nil, days_back: nil, since: nil, until: nil, page: nil, per: nil)
25
+ query = {
26
+ search: search,
27
+ include_archived: include_archived,
28
+ include_system: include_system,
29
+ include_templates: include_templates,
30
+ include_metrics: include_metrics,
31
+ days_back: days_back,
32
+ **window_params(since, binding.local_variable_get(:until)),
33
+ page: page,
34
+ per: per
35
+ }.compact
36
+
37
+ workloads, response = get_json_with_headers(list_url(query), ERROR_NOUN)
38
+ raise Error, "#{ERROR_NOUN} response was not a JSON array" unless workloads.is_a?(Array)
39
+
40
+ WorkloadSearchResult.new(
41
+ workloads: workloads,
42
+ pagination: Pagination.from_headers(response, items: workloads, page: page, per: per)
43
+ ).freeze
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "uri"
5
+ require "json"
6
+ require_relative "errors"
7
+ require_relative "read_requests"
8
+
9
+ module Coolhand
10
+ # POST/DELETE counterpart of {ReadRequests} for callers that act on the response: raises on any
11
+ # failure where {ApiService#send_request} logs and returns nil. Timeouts are the read ones, for
12
+ # the same server-side statement-bound reason. Mix into an {ApiService} subclass: it relies on
13
+ # `api_key`, `apply_headers` and `format_error_body` from there and the shared HTTP helpers from
14
+ # {ReadRequests}.
15
+ module WriteRequests
16
+ protected
17
+
18
+ # Returns the parsed body, or nil for an empty one (204).
19
+ def request_json(verb, url, noun, body: nil)
20
+ raise Error, "#{noun} request failed: an API key is required" unless Coolhand.required_field?(api_key)
21
+
22
+ response = perform_write(verb, url, noun, body)
23
+
24
+ raise_http_error(response, noun) unless response.is_a?(Net::HTTPSuccess)
25
+
26
+ response.body.to_s.strip.empty? ? nil : parse_json_body(response.body, noun)
27
+ end
28
+
29
+ private
30
+
31
+ def perform_write(verb, url, noun, body)
32
+ uri = url.is_a?(URI::Generic) ? url : URI.parse(url.to_s)
33
+ http = build_http(uri)
34
+
35
+ request = Net::HTTP.const_get(verb.to_s.capitalize).new(uri.request_uri)
36
+ apply_headers(request, "Accept" => "application/json", "X-API-Key" => api_key)
37
+ unless body.nil?
38
+ apply_headers(request, "Content-Type" => "application/json")
39
+ request.body = JSON.generate(body).force_encoding("UTF-8")
40
+ end
41
+
42
+ Coolhand.without_capture { http.request(request) }
43
+ rescue StandardError => e
44
+ raise Error, "#{noun} request failed: #{e.message}"
45
+ end
46
+ end
47
+ end
data/lib/coolhand.rb CHANGED
@@ -10,12 +10,16 @@ require_relative "coolhand/errors"
10
10
  require_relative "coolhand/version"
11
11
  require_relative "coolhand/configuration"
12
12
  require_relative "coolhand/collector"
13
+ require_relative "coolhand/log_queue"
13
14
  require_relative "coolhand/base_interceptor"
14
15
  require_relative "coolhand/net_http_interceptor"
15
16
  require_relative "coolhand/api_service"
16
17
  require_relative "coolhand/logger_service"
17
18
  require_relative "coolhand/feedback_service"
18
19
  require_relative "coolhand/template_service"
20
+ require_relative "coolhand/optimization_feedback_link_service"
21
+ require_relative "coolhand/workload_service"
22
+ require_relative "coolhand/log_service"
19
23
  require_relative "coolhand/webhook_interceptor"
20
24
 
21
25
  # The main module for the Coolhand gem.
@@ -86,6 +90,13 @@ module Coolhand
86
90
  Thread.current[:coolhand_capture_override] = previous
87
91
  end
88
92
 
93
+ # Blocks until log requests queued by async logging have been sent, or the timeout (in
94
+ # seconds) elapses. Returns true if everything was sent. Pending logs are also flushed
95
+ # automatically at process exit.
96
+ def flush(timeout: LogQueue::DEFAULT_FLUSH_TIMEOUT)
97
+ LogQueue.flush(timeout)
98
+ end
99
+
89
100
  # A simple logger that respects the 'silent' configuration option.
90
101
  def log(message)
91
102
  return if configuration.silent
@@ -113,6 +124,24 @@ module Coolhand
113
124
  TemplateService.new
114
125
  end
115
126
 
127
+ # Creates a new WorkloadService instance, for listing workloads (with optional cost and
128
+ # performance metrics). Needs the private API key.
129
+ def workload_service
130
+ WorkloadService.new
131
+ end
132
+
133
+ # Creates a new LogService instance, for reading logs back out of Coolhand. Distinct from
134
+ # {logger_service}, which writes them. Needs the private API key.
135
+ def log_service
136
+ LogService.new
137
+ end
138
+
139
+ # Creates a new OptimizationFeedbackLinkService instance, for linking feedback to an
140
+ # optimization as evidence. Needs the private API key.
141
+ def optimization_feedback_link_service
142
+ OptimizationFeedbackLinkService.new
143
+ end
144
+
116
145
  def required_field?(value)
117
146
  return false if value.nil?
118
147
  return false if value.respond_to?(:empty?) && value.empty?
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.7.0
4
+ version: 0.8.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Michael Carroll
@@ -9,7 +9,7 @@ authors:
9
9
  autorequire:
10
10
  bindir: exe
11
11
  cert_chain: []
12
- date: 2026-09-20 00:00:00.000000000 Z
12
+ date: 2026-10-14 00:00:00.000000000 Z
13
13
  dependencies:
14
14
  - !ruby/object:Gem::Dependency
15
15
  name: base64
@@ -48,10 +48,13 @@ files:
48
48
  - docs/anthropic.md
49
49
  - docs/configuration.md
50
50
  - docs/elevenlabs.md
51
+ - docs/feedback-links.md
51
52
  - docs/feedback.md
53
+ - docs/log-search.md
52
54
  - docs/openai.md
53
55
  - docs/template-search.md
54
56
  - docs/vertex.md
57
+ - docs/workload-search.md
55
58
  - lib/coolhand.rb
56
59
  - lib/coolhand/api_service.rb
57
60
  - lib/coolhand/base_interceptor.rb
@@ -62,17 +65,23 @@ files:
62
65
  - lib/coolhand/default_intercept_path_patterns.yml
63
66
  - lib/coolhand/errors.rb
64
67
  - lib/coolhand/feedback_service.rb
68
+ - lib/coolhand/log_queue.rb
69
+ - lib/coolhand/log_service.rb
65
70
  - lib/coolhand/logger_service.rb
66
71
  - lib/coolhand/net_http_interceptor.rb
67
72
  - lib/coolhand/open_ai/batch_result_processor.rb
68
73
  - lib/coolhand/open_ai/webhook_id_store.rb
69
74
  - lib/coolhand/open_ai/webhook_validator.rb
75
+ - lib/coolhand/optimization_feedback_link_service.rb
70
76
  - lib/coolhand/pagination.rb
77
+ - lib/coolhand/read_query.rb
71
78
  - lib/coolhand/read_requests.rb
72
79
  - lib/coolhand/template_service.rb
73
80
  - lib/coolhand/version.rb
74
81
  - lib/coolhand/vertex/batch_result_processor.rb
75
82
  - lib/coolhand/webhook_interceptor.rb
83
+ - lib/coolhand/workload_service.rb
84
+ - lib/coolhand/write_requests.rb
76
85
  - sig/coolhand/ruby.rbs
77
86
  homepage: https://coolhandlabs.com/
78
87
  licenses: