coolhand 0.5.0 → 0.6.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.
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+ require_relative "api_service"
5
+ require_relative "pagination"
6
+
7
+ module Coolhand
8
+ # Rows stay plain Symbol-keyed Hashes so a field the server adds later is not dropped in transit.
9
+ TemplateSearchResult = Struct.new(:templates, :pagination, keyword_init: true)
10
+
11
+ # Read-only. Requires the client's **private** key — the public key is write-only here and is
12
+ # rejected like an invalid one.
13
+ #
14
+ # Not a port of the MCP `search_templates` tool and does not agree with its `log_count`.
15
+ # See docs/template-search.md.
16
+ class TemplateService < ApiService
17
+ ERROR_NOUN = "Template"
18
+ BLANK_ID_MESSAGE = "get_template: id must be a non-empty template hashid"
19
+
20
+ def initialize
21
+ super("v2/llm_request_templates")
22
+ end
23
+
24
+ # Filters, error semantics and server behaviour: docs/template-search.md.
25
+ def search_templates(search: nil, workload_id: nil, status: nil, include_deprecated: nil,
26
+ include_system: nil, page: nil, per: nil)
27
+ query = {
28
+ search: search,
29
+ workload_id: workload_id,
30
+ status: status,
31
+ include_deprecated: include_deprecated,
32
+ include_system: include_system,
33
+ page: page,
34
+ per: per
35
+ }.compact
36
+
37
+ templates, response = get_json_with_headers(list_url(query), ERROR_NOUN)
38
+ raise Error, "#{ERROR_NOUN} response was not a JSON array" unless templates.is_a?(Array)
39
+
40
+ TemplateSearchResult.new(
41
+ templates: templates,
42
+ pagination: Pagination.from_headers(response, items: templates, page: page, per: per)
43
+ ).freeze
44
+ end
45
+
46
+ # 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\-._~]/)
74
+ end
75
+ end
76
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Coolhand
4
- VERSION = "0.5.0"
4
+ VERSION = "0.6.0"
5
5
  end
@@ -1,12 +1,27 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "../../coolhand"
4
+
3
5
  module Coolhand
4
6
  module Vertex
5
7
  class BatchResultProcessor
6
- attr_reader :batch_info
8
+ # Global endpoint, not the job's region-specific one — matches the
9
+ # "aiplatform.googleapis.com" entry in default_intercept_addresses.yml
10
+ # so backend URL-shape classification stays consistent with the rest
11
+ # of the gem's Vertex traffic. The URL this produces always contains
12
+ # "/batchPredictionJobs/", which is also the default client-side
13
+ # exclude_api_patterns entry — that's harmless here since this class
14
+ # sends via BaseInterceptor/ApiService directly and never goes through
15
+ # NetHttpInterceptor's intercept/exclude filtering.
16
+ VERTEX_API_BASE_URL = "https://aiplatform.googleapis.com/v1/"
17
+ SOURCE_API = "vertex"
18
+ VALID_NAME_PATTERN = %r{\Aprojects/[^/?#\s]+/locations/[^/?#\s]+/batchPredictionJobs/[^/?#\s]+\z}
19
+
20
+ attr_reader :batch_info, :model
7
21
 
8
- def initialize(batch_info:)
22
+ def initialize(batch_info:, model: nil)
9
23
  @batch_info = batch_info
24
+ @model = model
10
25
  end
11
26
 
12
27
  def call(batch_results = [])
@@ -16,7 +31,7 @@ module Coolhand
16
31
  when "JOB_STATE_PENDING", "JOB_STATE_RUNNING", "JOB_STATE_QUEUED"
17
32
  Rails.logger.info("[Interceptor] Vertex batch #{batch_info} still processing")
18
33
  when "JOB_STATE_SUCCEEDED"
19
- batch_results.each { |batch_item| process_completed_batch(batch_item) }
34
+ process_completed_batch(batch_results)
20
35
  when "JOB_STATE_FAILED"
21
36
  handle_failed_batch
22
37
  else
@@ -28,56 +43,98 @@ module Coolhand
28
43
 
29
44
  private
30
45
 
31
- def process_completed_batch(batch_item)
32
- send_complete_request_log(request_id: SecureRandom.hex(16),
46
+ def process_completed_batch(batch_results)
47
+ name = batch_info["name"].to_s.sub(%r{\A/+}, "")
48
+ unless name.match?(VALID_NAME_PATTERN)
49
+ Rails.logger.error("[Interceptor] Vertex batch #{batch_info['displayName']} has a missing or " \
50
+ "invalid job resource name (#{batch_info['name'].inspect}); skipping request log")
51
+ return
52
+ end
53
+
54
+ begin
55
+ start_time = Time.iso8601(batch_info["startTime"])
56
+ end_time = Time.iso8601(batch_info["endTime"])
57
+ rescue TypeError, ArgumentError
58
+ Rails.logger.error("[Interceptor] Vertex batch #{batch_info['displayName']} has a missing or " \
59
+ "invalid startTime/endTime; skipping request log")
60
+ return
61
+ end
62
+
63
+ if end_time < start_time
64
+ Rails.logger.warn("[Interceptor] Vertex batch #{batch_info['displayName']} has an endTime before " \
65
+ "its startTime; sending request log(s) with duration_ms clamped to 0")
66
+ end_time = start_time
67
+ end
68
+
69
+ duration_ms = ((end_time - start_time) * 1000).to_i
70
+ url = "#{VERTEX_API_BASE_URL}#{name}"
71
+ resolved = resolved_model
72
+
73
+ sent = batch_results.count do |batch_item|
74
+ send_item_log(batch_item, url: url, model: resolved, start_time: start_time, end_time: end_time,
75
+ duration_ms: duration_ms)
76
+ end
77
+
78
+ # "Sent" here means dispatched to BaseInterceptor without a local
79
+ # error (e.g. a malformed batch_item) — BaseInterceptor swallows any
80
+ # downstream delivery failure itself, so this can't confirm the
81
+ # Coolhand API actually received the log.
82
+ if sent == batch_results.size
83
+ Rails.logger.info("[Interceptor] Dispatched #{sent} result(s) for Vertex batch " \
84
+ "#{batch_info['displayName']} for logging")
85
+ else
86
+ Rails.logger.warn("[Interceptor] Dispatched #{sent}/#{batch_results.size} result(s) for Vertex " \
87
+ "batch #{batch_info['displayName']} for logging — " \
88
+ "#{batch_results.size - sent} item(s) were malformed and skipped")
89
+ end
90
+ end
91
+
92
+ def send_item_log(batch_item, url:, model:, start_time:, end_time:, duration_ms:)
93
+ unless batch_item.is_a?(Hash) && (batch_item.key?("request") || batch_item.key?("response"))
94
+ Rails.logger.error("[Interceptor] Vertex batch #{batch_info['displayName']} has a malformed result " \
95
+ "item (#{batch_item.class}); skipping request log")
96
+ return false
97
+ end
98
+
99
+ BaseInterceptor.send_complete_request_log(
100
+ request_id: SecureRandom.hex(16),
33
101
  method: "POST",
34
- url: batch_info["name"],
102
+ url: url,
103
+ source_api: SOURCE_API,
104
+ model: model,
105
+ request_headers: {},
35
106
  request_body: batch_item["request"],
107
+ response_headers: {},
36
108
  response_body: batch_item["response"],
37
109
  status_code: 200,
38
- start_time: batch_info["startTime"],
39
- end_time: batch_info["endTime"])
40
-
41
- Rails.logger.info("[Interceptor] Successfully processed Vertex batch #{batch_info['displayName']}")
110
+ start_time: start_time,
111
+ end_time: end_time,
112
+ duration_ms: duration_ms,
113
+ is_streaming: false
114
+ )
115
+ true
42
116
  rescue StandardError => e
43
117
  Rails.logger.error("[Interceptor] Failed to send request log: #{e.message}")
118
+ false
44
119
  end
45
120
 
46
- def send_complete_request_log(request_id:, method:, url:, request_body:, response_body:, status_code:,
47
- start_time:, end_time:)
48
- start_time = Time.iso8601(start_time)
49
- end_time = Time.iso8601(end_time)
50
- duration_ms = ((end_time - start_time) * 1000).to_i
121
+ def resolved_model
122
+ candidate = [model, batch_info["model"]].find { |c| Coolhand.required_field?(c) }&.to_s&.strip
123
+ return if candidate.nil?
51
124
 
52
- request_data = {
53
- raw_request: {
54
- id: request_id,
55
- timestamp: start_time,
56
- method: method.to_s.downcase,
57
- url: url,
58
- headers: {},
59
- request_body: request_body,
60
- response_headers: {},
61
- response_body: response_body,
62
- status_code: status_code,
63
- duration_ms: duration_ms,
64
- completed_at: end_time,
65
- is_streaming: false
66
- }
67
- }
68
-
69
- api_service = Coolhand::ApiService.new
70
- api_service.send_llm_request_log(request_data)
71
-
72
- Coolhand.log "📤 Sent complete request/response log for #{request_id} (duration: #{duration_ms}ms)"
73
- rescue StandardError => e
74
- Coolhand.log "❌ Error sending complete request log: #{e.message}"
125
+ # Only normalize genuine Vertex model resource paths (e.g.
126
+ # "publishers/google/models/gemini-2.0-flash" or
127
+ # "projects/P/locations/L/models/M@1") down to their bare id. A
128
+ # caller-supplied `model:` might itself be a provider-qualified slug
129
+ # that happens to contain a slash (e.g. "meta-llama/Llama-3") —
130
+ # leave anything that isn't a resource path untouched.
131
+ candidate.match?(%r{\A(publishers|projects)/}) ? candidate.split("/").last : candidate
75
132
  end
76
133
 
77
134
  # TODO: implement API to handle failed batch results and display errors on dashboard page
78
135
  def handle_failed_batch
79
- Rails.logger.error("[Interceptor] Vertex batch for #{batch_info['displayName']} " \
80
- "failed: #{batch_info['error']['message']}")
136
+ message = batch_info["error"].is_a?(Hash) ? batch_info["error"]["message"] : batch_info["error"]
137
+ Rails.logger.error("[Interceptor] Vertex batch for #{batch_info['displayName']} failed: #{message}")
81
138
  end
82
139
  end
83
140
  end
@@ -1,5 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "open_ai/webhook_validator"
4
+ require_relative "open_ai/batch_result_processor"
5
+
3
6
  module Coolhand
4
7
  module WebhookInterceptor
5
8
  def intercept_batch_request
@@ -14,10 +17,18 @@ module Coolhand
14
17
  end
15
18
 
16
19
  payload = JSON.parse(@validator.payload)
20
+ raise TypeError, "webhook payload must be a JSON object, got #{payload.class}" unless payload.is_a?(Hash)
17
21
 
18
22
  process_event(payload)
19
23
  rescue StandardError => e
24
+ # Fail closed: any error here (malformed payload, a bug in
25
+ # process_event/BatchResultProcessor, etc.) must still halt the
26
+ # before_action chain. Falling through without calling `head` would
27
+ # let the controller action run for a request whose webhook
28
+ # signature was never confirmed valid.
20
29
  Rails.logger.error("[Interceptor] Failed to intercept batch request: #{e.message}")
30
+ head :unauthorized
31
+ false
21
32
  end
22
33
 
23
34
  def webhook_secret
data/lib/coolhand.rb CHANGED
@@ -1,11 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "uri"
4
- require "faraday"
5
4
  require "securerandom"
6
5
  require "json"
7
6
  require "base64"
7
+ require "time"
8
8
 
9
+ require_relative "coolhand/errors"
9
10
  require_relative "coolhand/version"
10
11
  require_relative "coolhand/configuration"
11
12
  require_relative "coolhand/collector"
@@ -14,13 +15,12 @@ require_relative "coolhand/net_http_interceptor"
14
15
  require_relative "coolhand/api_service"
15
16
  require_relative "coolhand/logger_service"
16
17
  require_relative "coolhand/feedback_service"
18
+ require_relative "coolhand/template_service"
17
19
  require_relative "coolhand/webhook_interceptor"
18
20
 
19
21
  # The main module for the Coolhand gem.
20
22
  # It provides the configuration interface and initializes the patching.
21
23
  module Coolhand
22
- class Error < StandardError; end
23
-
24
24
  # Class-level instance variables to hold the configuration
25
25
  @configuration = Configuration.new
26
26
 
@@ -62,12 +62,11 @@ module Coolhand
62
62
 
63
63
  return yield unless configuration.enabled
64
64
 
65
- patched = NetHttpInterceptor.patched?
66
65
  NetHttpInterceptor.patch!
67
66
  begin
68
67
  yield
69
68
  ensure
70
- NetHttpInterceptor.unpatch! unless patched
69
+ NetHttpInterceptor.unpatch!
71
70
  end
72
71
  end
73
72
 
@@ -104,6 +103,12 @@ module Coolhand
104
103
  LoggerService.new
105
104
  end
106
105
 
106
+ # Creates a new TemplateService instance, for reading LLM request templates back out of
107
+ # Coolhand. Needs the private API key - the public key is write-only on this API.
108
+ def template_service
109
+ TemplateService.new
110
+ end
111
+
107
112
  def required_field?(value)
108
113
  return false if value.nil?
109
114
  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.5.0
4
+ version: 0.6.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-07-31 00:00:00.000000000 Z
12
+ date: 2026-09-13 00:00:00.000000000 Z
13
13
  dependencies:
14
14
  - !ruby/object:Gem::Dependency
15
15
  name: base64
@@ -26,23 +26,21 @@ dependencies:
26
26
  - !ruby/object:Gem::Version
27
27
  version: '0.2'
28
28
  description: Automatically intercept and log LLM requests from Ruby applications.
29
- Supports OpenAI, official Anthropic gem, ruby-anthropic gem, and other Faraday-based
30
- libraries. Features dual interceptor architecture, streaming support, thread-safe
31
- operation, and automatic duplicate request prevention.
29
+ Supports OpenAI, official Anthropic gem, ruby-anthropic gem, Google Gemini, Google
30
+ Vertex AI, AWS Bedrock, OpenRouter, and any other library using Net::HTTP (directly,
31
+ or via Faraday's default adapter). Features a single unified Net::HTTP interceptor,
32
+ streaming support, thread-safe operation, and automatic duplicate request prevention.
32
33
  email:
33
34
  - mc@coolhandlabs.com
34
35
  executables: []
35
36
  extensions: []
36
37
  extra_rdoc_files: []
37
38
  files:
38
- - ".claude/skills/loop-review/SKILL.md"
39
- - ".claude/skills/prep-release/SKILL.md"
40
- - ".idea/coolhand-ruby.iml"
41
39
  - ".rspec"
42
40
  - ".rubocop.yml"
43
41
  - ".simplecov"
42
+ - AGENTS.harness.md
44
43
  - CHANGELOG.md
45
- - CLAUDE.md
46
44
  - LICENSE
47
45
  - README.md
48
46
  - Rakefile
@@ -51,6 +49,9 @@ files:
51
49
  - docs/configuration.md
52
50
  - docs/elevenlabs.md
53
51
  - docs/feedback.md
52
+ - docs/openai.md
53
+ - docs/template-search.md
54
+ - docs/vertex.md
54
55
  - lib/coolhand.rb
55
56
  - lib/coolhand/api_service.rb
56
57
  - lib/coolhand/base_interceptor.rb
@@ -58,11 +59,17 @@ files:
58
59
  - lib/coolhand/configuration.rb
59
60
  - lib/coolhand/default_exclude_api_patterns.yml
60
61
  - lib/coolhand/default_intercept_addresses.yml
62
+ - lib/coolhand/default_intercept_path_patterns.yml
63
+ - lib/coolhand/errors.rb
61
64
  - lib/coolhand/feedback_service.rb
62
65
  - lib/coolhand/logger_service.rb
63
66
  - lib/coolhand/net_http_interceptor.rb
64
67
  - lib/coolhand/open_ai/batch_result_processor.rb
68
+ - lib/coolhand/open_ai/webhook_id_store.rb
65
69
  - lib/coolhand/open_ai/webhook_validator.rb
70
+ - lib/coolhand/pagination.rb
71
+ - lib/coolhand/read_requests.rb
72
+ - lib/coolhand/template_service.rb
66
73
  - lib/coolhand/version.rb
67
74
  - lib/coolhand/vertex/batch_result_processor.rb
68
75
  - lib/coolhand/webhook_interceptor.rb
@@ -1,112 +0,0 @@
1
- ---
2
- name: loop-review
3
- description: |
4
- Iteratively runs code review against the current diff, applies fixes, and
5
- re-reviews until a round comes back clean (or a safety cap is hit). Use
6
- when the user types /loop-review, asks to "loop the review", "review
7
- until clean", "keep reviewing and fixing until nothing's left", or wants
8
- a self-healing code review cycle instead of a single one-shot pass.
9
- user_invocable: true
10
- version: 0.3.0
11
- ---
12
-
13
- # Loop Review
14
-
15
- This skill runs `/code-review` repeatedly against the working diff, applies
16
- fixes between rounds, and re-reviews until a round finds nothing new or a
17
- safety cap is reached. Use it after a merge, a large diff, or whenever a
18
- single review pass isn't enough to converge on a clean state.
19
-
20
- ## Scope
21
-
22
- Default scope is the diff between the current branch and its merge-base
23
- with the repo's base branch (`git diff $(git merge-base origin/main
24
- HEAD)...HEAD`, or `origin/main` substituted with whatever base branch
25
- applies). If `$ARGUMENTS` names a path or a narrower scope, review only
26
- that instead of the full diff.
27
-
28
- This skill is deliberately diff-scoped. For a whole-package audit before a
29
- release (full-codebase security red-team, docs review, everything since
30
- the last tag) use `/prep-release` instead.
31
-
32
- `$ARGUMENTS` may also contain:
33
- - An effort level to pass through to `/code-review` (`low`/`medium`/
34
- `high`/`high→max`/`ultra`). Default: `medium`.
35
- - A round cap override, e.g. `--max-rounds 3`. Default: 5.
36
-
37
- ## The round loop
38
-
39
- 1. Invoke `/code-review <effort> --fix` (via the `Skill` tool) against the
40
- current scope.
41
- 2. **Dry round (0 findings) → converged.** Stop and move to verification.
42
- 3. **Findings found and fixed** → do not declare victory yet. Run another
43
- round to confirm the fixes didn't introduce a regression and that
44
- nothing was missed.
45
- 4. **No-progress detection**: if two consecutive rounds return the same
46
- non-empty set of findings, `--fix` isn't resolving them mechanically
47
- (likely a design/architecture call that needs a human). Stop looping,
48
- list the stuck findings, and hand them to the user instead of retrying
49
- forever.
50
- 5. **Safety cap**: if the round cap is reached without converging or
51
- getting stuck, stop and report the remaining findings — don't loop
52
- silently past the cap.
53
-
54
- Each round's fixes should stay reviewable: don't squash multiple rounds
55
- into one silent edit. Note per-round changes in the final summary so the
56
- user can inspect them with `git diff`.
57
-
58
- ## Review criteria
59
-
60
- Beyond whatever `/code-review` already checks for correctness bugs and
61
- reuse/simplification/efficiency, every round in a Ruby gem repo like this
62
- one should also flag:
63
-
64
- - **Ruby idiom / DRY**: semantic, expressive naming; no duplicated logic
65
- that should be extracted into a shared method; idiomatic use of
66
- Ruby/Enumerable over manual loops where it reads better; no needless
67
- boilerplate.
68
- - **Gem publishing discipline**: don't break public interfaces unless
69
- necessary.
70
- - If a break is necessary, it must come with: a `CHANGELOG.md` entry in
71
- Keep a Changelog format (this repo already follows that format — match
72
- the style of existing entries, e.g. plain-English migration notes like
73
- the `0.5.0` entry) and a version bump in `lib/coolhand/version.rb` that
74
- matches SemVer (patch = fix, minor = backward-compatible addition,
75
- minor = breaking change while pre-1.0, consistent with this repo's own
76
- versioning history).
77
- - Check the optional-provider-dependency rule from this repo's
78
- `CLAUDE.md`: provider SDK `require`s (`openai`, `anthropic`,
79
- `google-generativeai`, etc.) must stay scoped to the file that uses
80
- them and lazy-loaded, never added to `lib/coolhand.rb` or the gemspec
81
- as a hard dependency.
82
- - **Security**: injection risks, unsafe deserialization, secrets or
83
- credentials logged or committed, unvalidated input crossing a trust
84
- boundary, and anything touching how API keys/tokens are handled,
85
- stored, or transmitted.
86
-
87
- ## Post-loop verification
88
-
89
- Once the loop converges (or stops early per the rules above), run the
90
- project's lint/test command and include the result in the final summary.
91
- For this repo that's `bundle exec rake` (runs `rspec` then `rubocop`, per
92
- the `Rakefile`). If a fix round changed something outside this repo's
93
- usual toolchain, discover the right command instead of assuming.
94
-
95
- ## Rationalizations to resist
96
-
97
- - *"The first round already looked clean, I don't need a confirming
98
- round."* A fix round can introduce its own regression. Always re-review
99
- after applying fixes before declaring convergence.
100
- - *"Rubocop passed, so the review is done."* Lint passing is not the same
101
- as the review being clean — lint doesn't check the criteria above
102
- (interface breakage, changelog/version discipline, security). Run both.
103
- - *"This finding keeps coming back, I'll just keep re-running --fix and
104
- it'll eventually take."* If the same non-empty finding set repeats
105
- across two rounds, `--fix` isn't going to resolve it. Stop and surface
106
- it — looping past that point just burns rounds for no gain.
107
-
108
- ## Safety
109
-
110
- - Never force-push or amend existing commits as part of this loop.
111
- - The skill only edits the working tree; committing and pushing stays with
112
- the user.