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.
- checksums.yaml +4 -4
- data/AGENTS.harness.md +49 -14
- data/CHANGELOG.md +22 -1
- data/README.md +34 -1
- data/docs/configuration.md +19 -0
- data/docs/feedback-links.md +48 -0
- data/docs/log-search.md +102 -0
- data/docs/openai.md +1 -1
- data/docs/template-search.md +46 -4
- data/docs/vertex.md +1 -0
- data/docs/workload-search.md +116 -0
- data/lib/coolhand/api_service.rb +4 -3
- data/lib/coolhand/base_interceptor.rb +4 -4
- data/lib/coolhand/configuration.rb +2 -1
- data/lib/coolhand/errors.rb +2 -1
- data/lib/coolhand/log_queue.rb +179 -0
- data/lib/coolhand/log_service.rb +67 -0
- data/lib/coolhand/net_http_interceptor.rb +142 -137
- data/lib/coolhand/open_ai/batch_result_processor.rb +30 -29
- data/lib/coolhand/optimization_feedback_link_service.rb +85 -0
- data/lib/coolhand/read_query.rb +61 -0
- data/lib/coolhand/read_requests.rb +26 -13
- data/lib/coolhand/template_service.rb +13 -29
- data/lib/coolhand/version.rb +1 -1
- data/lib/coolhand/vertex/batch_result_processor.rb +9 -1
- data/lib/coolhand/workload_service.rb +46 -0
- data/lib/coolhand/write_requests.rb +47 -0
- data/lib/coolhand.rb +29 -0
- metadata +11 -2
|
@@ -16,7 +16,9 @@ module Coolhand
|
|
|
16
16
|
|
|
17
17
|
case batch_info["status"]
|
|
18
18
|
when "completed"
|
|
19
|
-
|
|
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 =
|
|
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
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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},
|
|
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 =
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
data/lib/coolhand/version.rb
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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-
|
|
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:
|