coolhand 0.5.1 → 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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 25393afdf95625b624ca2910cb3017685705529e9628a5d15cb3bdffdf122d52
4
- data.tar.gz: 3e478c3b8494d5fef5bc87896fbf69cb9f93d088029b9a9745563c9a789b4473
3
+ metadata.gz: a3c1e767ea245f56520ee634e48cd2f12c6dc06be79b3998537fea9e9b8ea9ff
4
+ data.tar.gz: aad0485d474aa1e3f796f2af30083304a1ed704ede8428ddc4b0c9a4ff32678e
5
5
  SHA512:
6
- metadata.gz: ad635f9377f6cb81967fc24b0c2aa9f9938c41e308c6b98511ee0139b9938e01ea766baf72a085700866c69ac50207da11e29f04d30a94ad3b487d3e22243d9e
7
- data.tar.gz: d6ef0baf4e37d29b36a0d7a2d6a41822f551a0a753a2dc963c0da9eed60b90bf29b5f0015caa8a628c6f340ec9cb2b3ef2c83ecb9b59fbcec909d70f396603df
6
+ metadata.gz: 2b497e1401c64e9eb6a6b1bbb061ecb359985a39f8dd95ce8018406d36e3c49ba061e5557299d4e6ee00498f8f8bf91ac9f3b91e56a32f5b08af599f5ef16768
7
+ data.tar.gz: 84d13bff930f045607270849ad02fd5c8548b15b39f2043636c868bcfe857e04fab4c0ae113fe4e5872ca299d197c83b04c2f0adce5bffe3723c4bacd5890b03
data/AGENTS.harness.md ADDED
@@ -0,0 +1,139 @@
1
+ # RUBY agent — API client harness
2
+
3
+ You are the **ruby agent**, working in the `coolhand-ruby` gem repo. You wrap one server
4
+ endpoint, prove it against the live local server, open a PR, and **stop**.
5
+
6
+ You are a dead end in the tree. You launch nobody.
7
+
8
+ This file is self-contained. You do not share a context window with the agent that
9
+ launched you.
10
+
11
+ ---
12
+
13
+ ## 0. Your inputs
14
+
15
+ ```
16
+ node <workspaceRoot>/coolhand/harness/harness.mjs context --run <RUN_DIR>
17
+ ```
18
+
19
+ | field | meaning |
20
+ |---|---|
21
+ | `baseUrl` | the live local server, already booted for you |
22
+ | `branch` | the shared branch name — use it here too |
23
+ | `specPath` | `coolhand/swagger/v2/coolhand_api.yaml` = **the API definition** |
24
+ | `dryRun` | if true, build and commit locally but **do not push and do not open a PR** — see `RESIST_RULES.md` → Dry runs |
25
+
26
+ Your channel is `ruby`. Your parent is `node`.
27
+
28
+ **Node opened a GitHub issue for you before it launched you.** That issue holds your
29
+ complete instructions and is the system of record for this work — read it first. Read your
30
+ own number back at any time with:
31
+
32
+ ```
33
+ node <workspaceRoot>/coolhand/harness/harness.mjs my-issue --run <RUN_DIR> --repo ruby
34
+ ```
35
+
36
+ ## 1. Read before writing any code
37
+
38
+ 1. **Your issue.** It is what you were asked to build.
39
+ 2. `<workspaceRoot>/coolhand/harness/RESIST_RULES.md` — the refuse list.
40
+ 3. The API definition at `specPath`. It is your only source of truth **for the endpoint's
41
+ contract** — paths, params, response fields, status codes.
42
+ 4. `coolhand-ruby/CLAUDE.md` — this repo's own rulebook. **It is authoritative.** Where it
43
+ disagrees with this harness file, CLAUDE.md wins — follow it and say so in your PR.
44
+
45
+ **Your issue links node's PR as the reference implementation. Use it for structure, not
46
+ for facts.** Node went first so you do not have to rediscover how a REST method fits into
47
+ a monitoring SDK — copy its *shape*: which class the method hangs off, how errors surface,
48
+ how pagination is exposed, what the method is called.
49
+
50
+ **Do not take a field name, a param, or a status code from node's code.** Those come from
51
+ the definition, every time. If node's wrapper and the definition disagree, that is not
52
+ yours to reconcile — it means one of them is wrong. Escalate (R3) and STOP.
53
+
54
+ Naming does not port. `searchFeedback` in node is `search_feedback` here. Match the
55
+ concept, not the characters.
56
+
57
+ ## 2. Build the wrapper
58
+
59
+ 1. `git checkout -b <branch>`
60
+ 2. Add the method following the existing pattern in `lib/coolhand/` —
61
+ `api_service.rb` and `feedback_service.rb` are your references.
62
+ 3. Require/expose it the way existing surface is exposed in `lib/coolhand.rb`.
63
+ 4. Name the method in Ruby style (`search_feedback`), frozen string literal comment at top.
64
+
65
+ **Do not restructure the gem to make this fit (R5).** If the endpoint cannot be expressed
66
+ inside the current architecture, escalate and STOP.
67
+
68
+ **Never add a provider SDK `require` (openai, anthropic, google-generativeai, etc.) to
69
+ `lib/coolhand.rb`, and never add one to `coolhand-ruby.gemspec` as a hard dependency (R5).**
70
+ Require it inside the file that actually uses it, executed only when that provider's
71
+ functionality is accessed. Clients may not use every provider and must not be forced to
72
+ install gems they do not need — apps consuming this as a path gem break on missing optional
73
+ dependencies. See `coolhand-ruby/CLAUDE.md`.
74
+
75
+ ## 3. Prove it against the real server
76
+
77
+ Not a mock. Make real calls to `baseUrl`.
78
+
79
+ ```
80
+ bundle exec rake
81
+ ```
82
+
83
+ That runs both RSpec and RuboCop — both must pass. **Never delete an assertion, mark a
84
+ spec pending, or rescue-and-swallow to get green (R4).**
85
+
86
+ **RSpec is the normal, correct test framework in this gem.** If you have seen the rule
87
+ that RSpec is only for API-definition docs — that is the `coolhand` Rails repo, which
88
+ migrated to Minitest and kept RSpec solely to generate the API docs. It does not apply
89
+ here. Write RSpec.
90
+
91
+ ## 4. Escalate the moment something does not make sense
92
+
93
+ Escalate to **node**, your parent — not to the server. If it is a question about the API
94
+ definition, node passes it up and relays the answer back down. You never message the
95
+ server directly; the tree only has parents and children.
96
+
97
+ ```
98
+ node <workspaceRoot>/coolhand/harness/harness.mjs send --run <RUN_DIR> --channel ruby \
99
+ --from ruby --to node --kind escalation --text "R2: definition exposes POST /get_optimization"
100
+ ```
101
+
102
+ Then wait, and stop working while you wait:
103
+
104
+ ```
105
+ node <workspaceRoot>/coolhand/harness/harness.mjs wait --run <RUN_DIR> --channel ruby --for ruby --after <messageId>
106
+ ```
107
+
108
+ Name the rule number (`R1`–`R5`). Do not guess. Do not stub. Do not work around it.
109
+
110
+ ## 5. Run your review skill
111
+
112
+ **Mandatory, before you push.** Follow `<workspaceRoot>/coolhand/harness/RESIST_RULES.md`
113
+ → "Before you push: run your repo's review skill" (R8) — load
114
+ `.claude/skills/loop-review/SKILL.md` off disk and run it for real against your diff. Do
115
+ not approximate its steps manually; if you cannot spawn the reviewer subagent it calls
116
+ for, escalate to node (R8) and STOP rather than substitute a self-review.
117
+
118
+ ## 6. Open your PR — then STOP
119
+
120
+ **If `dryRun` is true, stop here.** Commit locally, report what you built, and push nothing.
121
+
122
+ 1. Push and open the PR in `coolhand-ruby`.
123
+ 2. Body must reference your issue with `Closes #N` so it auto-closes on merge, and must
124
+ say: **depends on the server PR — deploy that first.**
125
+ 3. Record it: `node <workspaceRoot>/coolhand/harness/harness.mjs pr --run <RUN_DIR> --repo ruby --url <url>`
126
+ 4. Post your review skill's Iteration Breakdown table (section 5) as a comment on this PR
127
+ — see `RESIST_RULES.md` → "After the loop exits."
128
+ 5. **Stop.** You launch no one. The tree ends with you on this branch.
129
+
130
+ ## 7. Done means
131
+
132
+ - [ ] Method exists, matches the API definition exactly
133
+ - [ ] `bundle exec rake` passes (RSpec + RuboCop)
134
+ - [ ] At least one spec hit the real local server, not a mock
135
+ - [ ] PR opened, recorded, references its issue, states its dependency on the server PR
136
+ - [ ] Your review skill ran for real (not approximated) and its Iteration Breakdown table
137
+ is posted as a comment on your PR
138
+ - [ ] Every field and status code came from the definition, not from node's code
139
+ - [ ] You launched no child agents
data/CHANGELOG.md CHANGED
@@ -7,6 +7,37 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.6.0] - 2026-09-12
11
+
12
+ ### Added
13
+ - **`Coolhand::TemplateService`, reached via `Coolhand.template_service`** — reads LLM request templates back out of Coolhand (`GET /api/v2/llm_request_templates`, `GET /api/v2/llm_request_templates/{id}`). `search_templates` filters and pages, returning a `Coolhand::TemplateSearchResult` of `templates` and `pagination`; `get_template` adds the `user_prompt_pattern`/`system_prompt_pattern` regexes the list omits. Both require the **private** API key — the public key is write-only on this API. Needs a server carrying these endpoints; a self-hosted one that predates them answers `404`. See [docs/template-search.md](docs/template-search.md) (#105).
14
+ - **`Coolhand::HttpError`** — raised by the read methods on a non-2xx response, carrying `#status` and `#body` so a caller can branch on `401` vs `404` vs `504` without matching message text. It subclasses `Coolhand::Error`, so `rescue Coolhand::Error` still catches everything this gem raises.
15
+ - **OpenCode added to default intercept addresses** — `opencode.ai` (OpenCode Zen's OpenAI-compatible gateway) is now monitored out of the box with no configuration required. Since matching is now host-based (see Security below), this also covers requests to `api.opencode.ai` without needing a separate entry, so misrouted requests show up in logs instead of vanishing (#97).
16
+ - **`examples/`** — runnable scripts (`openai_example.rb`, `anthropic_example.rb`, `elevenlabs_example.rb`) that make a real provider call through Coolhand, useful as a live smoke test. Excluded from the packaged gem. See [Examples](examples/README.md) (#109).
17
+
18
+ ### Changed
19
+ - **`config.intercept_addresses = []` (or `nil`) now logs a warning instead of silently doing nothing.** `intercept_addresses` is a required allow-list — an empty list would mean "capture nothing" — so `[]` has always been ignored and the previous value kept, unlike the deny-list `exclude_api_patterns`, which genuinely supports `= []` to disable exclusion. That asymmetry meant a user modeling `intercept_addresses` on its sibling setting got no error and no signal that anything happened. Behavior is unchanged; it's just no longer silent. Documented next to both settings in `docs/configuration.md` and the README (#89).
20
+ - **The read methods raise where the write methods return `nil`.** `send_llm_request_log`, `create_log` and `create_feedback` still log a failure and return `nil`; the new read methods raise `Coolhand::HttpError`, because a caller that asked for data has to be able to tell a `404` from a timeout from a genuinely empty result. Reads also allow 60 seconds where writes allow 5.
21
+ - **`search_templates` is not a port of the `search_templates` MCP tool and does not agree with its numbers.** `log_count` here counts only directly-collected client logs, so evals, bakeoff comparisons and synthetic logs are excluded; templates whose workload has been archived are returned rather than hidden.
22
+ - `Coolhand::Error` moved from `lib/coolhand.rb` into a new `lib/coolhand/errors.rb`, alongside `Coolhand::HttpError`, and is now required first. The constant, its superclass and its behaviour are unchanged.
23
+ - The GET half of `Coolhand::ApiService` now lives in a `Coolhand::ReadRequests` mixin (`lib/coolhand/read_requests.rb`), keeping the class inside this repo's 200-line budget, with `format_error_body` and `apply_headers` extracted from the POST path and shared by both halves. The one write-path behaviour change: a failed response body over 2000 characters is now truncated in the log line instead of printed in full.
24
+
25
+ ### Security
26
+ - **`NetHttpInterceptor`'s patched/unpatched state is no longer a plain boolean shared across threads.** `Coolhand.capture` snapshotted `NetHttpInterceptor.patched?` before yielding and used that snapshot at the end of a potentially long-running block (an in-flight HTTP request) to decide whether to unpatch. Two threads concurrently entering `Coolhand.capture` before the interceptor was already permanently patched could both observe `patched? == false`, and whichever thread's block finished first would call `unpatch!`, silently disabling interception for the other thread's still in-flight request. `patch!`/`unpatch!` are now a mutex-guarded reference count, so overlapping/nested callers compose correctly and the interceptor only actually unpatches once every outstanding caller has released it (#90). One behavior change: `patch!` is no longer idempotent on its own — every call now needs a matching `unpatch!` to release it, so an app that calls `Coolhand.configure` more than once (e.g. a reloader re-running an initializer) holds an extra, permanent reference each time rather than no-op'ing. This is harmless (interception simply stays on) but is a change from the prior behavior.
27
+ - **Captured request bodies are now capped in size and skipped entirely for non-JSON content types.** `Coolhand::NetHttpInterceptor#capture_request_body` previously read the entirety of `req.body_stream` (or `req.body`/the `body` param) into memory with no limit and forwarded it in full as `request_body` — since `intercept?` matches configured hosts on any path, a large upload to an endpoint like `/v1/files`, `/v1/audio/transcriptions`, or `/v1/batches` was held in memory and shipped to Coolhand's API in its entirety. A binary/multipart upload (audio, a batch JSONL file, a fine-tune corpus) is now skipped before it's ever read into memory for capture, and any body over the new `config.max_captured_body_bytes` (default 1 MB) is replaced with a placeholder instead of the full content. The real request to the LLM provider is unaffected — only what gets captured/logged changes (#88).
28
+ - **`Coolhand::OpenAi::WebhookValidator#should_enforce_strict_validation?` no longer fails open on unrecognized `Rails.env` names.** It previously blocklisted `"production"`/`"staging"` exactly, so any other spelling — `"prod"`, `"production-eu"`, a Heroku/Render `"review"` app env, or an unset `Rails.env` — fell into the permissive fallback path, where a missing/misconfigured webhook secret let an unsigned request pass as valid. It now uses a fail-safe allowlist of `"development"`/`"test"` — every other value, including `nil`, enforces strict validation (#84).
29
+ - **The full captured request/webhook body is no longer printed to stdout by default.** `Coolhand::ApiService#log_request_info` (reached via `LoggerService#log_to_api`/`#forward_webhook`) previously dumped the entire captured payload — including raw webhook bodies like voice-call transcripts — to stdout whenever `silent` was `false` (the default). In a containerized deploy, stdout typically flows straight into a log aggregator with a different retention/access model than Coolhand itself. It now logs only an identifier and a byte count by default; the full payload is still printed when `debug_mode` is explicitly enabled (#87).
30
+ - **A previously-valid OpenAI webhook request can no longer be replayed indefinitely.** `Coolhand::OpenAi::WebhookValidator#verify_signature` included `webhook-timestamp` and `webhook-id` in the signed payload, but never checked the timestamp for freshness or tracked the id for idempotency — a signature computed over a `webhook-timestamp` from the past (e.g. captured from a log aggregator, an APM trace, or a proxy log) still validated as `true`, and each replay re-triggered `Coolhand::OpenAi::BatchResultProcessor`, causing redundant OpenAI file downloads and duplicate request logs. It now rejects requests whose `webhook-timestamp` is more than `Coolhand.configuration.webhook_replay_tolerance_seconds` (default 300, matching the Standard Webhooks tolerance) away from the current time, and atomically claims `webhook-id` via a new `Coolhand.configuration.webhook_id_store` (default: an in-memory, per-process, TTL-bounded store — swappable for multi-process deployments), rejecting a request whose id was already claimed within the window. A missing `webhook-id` header (previously only the signature/timestamp headers were required) is now also treated as a missing-headers failure, rather than falling through as `nil` and letting two distinct requests both dedupe against the same `nil` key (#86).
31
+ - **`intercept_addresses` and `exclude_api_patterns` no longer match against the raw URL string.** `Coolhand::NetHttpInterceptor#intercept?`/`#excluded_by_pattern?` previously did `url.include?(entry)` against the whole URL, so a configured entry like `"api.openai.com"` could be triggered by an attacker-controlled URL that merely *mentions* it — `https://evil.com/redirect?to=api.openai.com` (wrong destination) or `https://api.openai.com.attacker.net/...` (suffix confusion) both wrongly matched, while `https://API.OPENAI.COM/...` wrongly didn't (case-sensitive). Matching is now done against the parsed `uri.host`, exact or dot-boundary suffix, case-insensitively — so a request can only be captured because it's actually going to a configured host. `exclude_api_patterns` now matches only `uri.path`, so a pattern like `/batchPredictionJobs/` can no longer be tripped by an unrelated request that merely has it in a query string.
32
+ - **Bare, hostless entries (`:generateContent`, `:streamGenerateContent`) have moved out of `intercept_addresses` into a new `intercept_path_patterns` setting.** These colon-suffixed path tokens (Google's API Discovery convention) had no host component, so under the old substring matching *any* URL whose path happened to contain them — e.g. `https://attacker.tld/:generateContent` — was wrongly captured. They're now matched only against the path of requests whose host is already `googleapis.com` or a subdomain of it, and can never match on their own — and only take effect at all if `intercept_addresses` still includes a `googleapis.com` host, so overriding `intercept_addresses` to exclude Google entirely also disables `intercept_path_patterns`.
33
+ - **The default AWS Bedrock entry (`bedrock-runtime`) is now the precise host pattern `bedrock-runtime.*.amazonaws.com`** instead of a bare substring, using new minimal wildcard support in host matching (a single `*` matches exactly one host label). Previously it matched anywhere `"bedrock-runtime"` appeared in a URL, not just the real Bedrock host.
34
+ - **`LoggerService#forward_webhook`'s `response_headers:` option is now redacted like every other header path in the gem.** It was passed straight through to Coolhand unredacted — unlike the sibling `headers:` option — so a caller forwarding a provider webhook alongside its own upstream response (e.g. `forward_webhook(..., response_headers: upstream_response.to_hash)`) shipped that response's `Authorization`/`Set-Cookie`/signature headers to Coolhand in plaintext.
35
+ - **`Coolhand::BaseInterceptor.sanitize_url` now redacts credentials embedded in a URL's userinfo component** (`https://user:pass@host/...`), not just sensitive query parameters. Previously a URL with no query string returned immediately, before userinfo was ever inspected.
36
+ - **`Coolhand::OpenAi::BatchResultProcessor`'s request-log URL is now run through the shared `sanitize_url`**, instead of being forwarded as-is — this path had its own hand-written logging call that predated (and didn't pick up) the shared header/URL sanitization used everywhere else in the gem.
37
+
38
+ ### Dependencies
39
+ - Bumped `json` to 2.21.2, flagged by `bundler-audit` (GHSA-9hj4-r449-hfvc: `JSON::ResumableParser#partial_value` dereferences a freed input buffer on truncated duplicate-key streams). `json` is a transitive dependency (via `rubocop`/`faraday`), not something this gem or its consumers pin directly.
40
+
10
41
  ## [0.5.1] - 2026-08-02
11
42
 
12
43
  ### Added
data/README.md CHANGED
@@ -81,6 +81,28 @@ feedback = feedback_service.create_feedback(
81
81
 
82
82
  For a full field reference and matching strategy, see [Feedback API →](docs/feedback.md).
83
83
 
84
+ ## Reading Templates
85
+
86
+ Read back the LLM request templates your logs are matched against. Unlike the logging and feedback methods, these require your **private** API key — the public key is write-only on this API and is rejected exactly like an invalid one.
87
+
88
+ ```ruby
89
+ require 'coolhand'
90
+
91
+ templates = Coolhand.template_service
92
+
93
+ result = templates.search_templates(search: 'summar', status: 'published')
94
+ result.templates.each { |template| puts "#{template[:name]} (#{template[:log_count]} logs)" }
95
+ result.pagination.total_count
96
+
97
+ # Prompt patterns come from a single-template fetch only
98
+ detail = templates.get_template(result.templates.first[:id])
99
+ detail[:user_prompt_pattern]
100
+ ```
101
+
102
+ Search is a parameter on the list endpoint, not a route of its own, and the `Unmatched` / `Ignored API Calls` system buckets are hidden unless you pass `include_system: true`. These are read methods, so they **raise** `Coolhand::HttpError` where the write methods log and return `nil`.
103
+
104
+ For the full filter reference, pagination, and error handling, see [Reading Templates →](docs/template-search.md).
105
+
84
106
  ## Rails Integration
85
107
 
86
108
  ### Configuration
@@ -156,7 +178,10 @@ end
156
178
  | `enabled` | Boolean | `true` | Set to `false` to disable all patching and validation (e.g. `Rails.env.production?`) |
157
179
  | `capture` | Boolean | `true` | Whether to capture and forward intercepted requests. Set to `false` to monitor without forwarding, then use [`Coolhand.with_capture`](#selective-capture) to re-enable selectively |
158
180
  | `silent` | Boolean | `false` | Whether to suppress console output |
159
- | `intercept_addresses` | Array | `["api.openai.com", "api.anthropic.com"]` | Array of API endpoint strings to monitor |
181
+ | `intercept_addresses` | Array | `["api.openai.com", "api.anthropic.com"]` | Array of API hosts to monitor (matched by host, not full URL). This is a required allow-list — `[]` is ignored (a warning is logged) rather than disabling capture; use `enabled` or `capture` for that. See [Configuration](docs/configuration.md) |
182
+ | `intercept_path_patterns` | Array | `[":generateContent", ":streamGenerateContent"]` | Path patterns to additionally monitor on Google API hosts — see [Configuration](docs/configuration.md) |
183
+ | `exclude_api_patterns` | Array | `["/batchPredictionJobs/"]` | Deny-list checked after `intercept_addresses`; matching paths are skipped. Unlike `intercept_addresses`, `exclude_api_patterns = []` genuinely disables exclusion. See [Configuration](docs/configuration.md) |
184
+ | `max_captured_body_bytes` | Integer | `1_000_000` | Maximum size of a captured JSON request body — oversized bodies are replaced with a placeholder. Non-JSON bodies (e.g. file/audio uploads) are always skipped regardless of size — see [Advanced Configuration](docs/configuration.md) |
160
185
 
161
186
  ## Usage Examples
162
187
 
@@ -315,6 +340,7 @@ The monitor works with multiple transport layers and Ruby libraries:
315
340
  - AWS Bedrock (OpenAI-compatible endpoint)
316
341
  - Cloudflare AI Gateway
317
342
  - OpenRouter
343
+ - OpenCode Zen (`opencode.ai`)
318
344
 
319
345
  **Universal Coverage**: Since most Ruby HTTP libraries use Net::HTTP under the hood, Coolhand's single interceptor provides comprehensive monitoring without needing library-specific integrations.
320
346
 
@@ -400,15 +426,18 @@ The monitor handles errors gracefully:
400
426
  - Failed API logging attempts are logged to console but don't interrupt your application
401
427
  - Invalid API keys will be reported but won't crash your app
402
428
  - Network issues are handled with appropriate error messages
429
+ - The read methods (`search_templates`, `get_template`) are the exception — they raise `Coolhand::HttpError`; see [Reading Templates →](docs/template-search.md)
403
430
 
404
431
  ## Documentation
405
432
 
406
433
  - **[Configuration](docs/configuration.md)** — Self-hosted deployments, base_url rules, debug mode, custom intercept addresses
407
434
  - **[Feedback API](docs/feedback.md)** — Full field reference, matching strategies, sentiment values
435
+ - **[Reading Templates](docs/template-search.md)** — Search LLM request templates and fetch a single one, prompt patterns included, using the private API key
408
436
  - **[Anthropic Integration](docs/anthropic.md)** — Official and community Anthropic Ruby gems, streaming, dual gem handling, and troubleshooting
409
437
  - **[ElevenLabs Integration](docs/elevenlabs.md)** — Webhook capture, feedback submission, and Rails integration
410
438
  - **[OpenAI Batch Webhook Handler](docs/openai.md)** — Handle OpenAI batch job completion events via webhook interception
411
439
  - **[Google Vertex AI Batch Result Logging](docs/vertex.md)** — Log completed Vertex AI batch prediction job results
440
+ - **[Examples](examples/README.md)** — Runnable scripts that make a real provider call through Coolhand, useful as a live smoke test
412
441
 
413
442
  ## Security
414
443
 
@@ -39,6 +39,22 @@ end
39
39
 
40
40
  Because it forces capture unconditionally and prints full request/response bodies to the console, only enable `debug_mode` in development — never in production or in an environment handling real user data.
41
41
 
42
+ ## Request Body Capture Limits
43
+
44
+ To avoid holding large uploads (batch JSONL files, fine-tune corpora, audio for transcription) in memory and shipping them to Coolhand in full, `config.max_captured_body_bytes` caps how much of a request body gets captured:
45
+
46
+ ```ruby
47
+ Coolhand.configure do |config|
48
+ config.max_captured_body_bytes = 2_000_000 # default: 1_000_000 (1 MB)
49
+ end
50
+ ```
51
+
52
+ **What happens at the limits:**
53
+ - A request body whose `Content-Type` doesn't look like JSON (e.g. `multipart/form-data`, `audio/mpeg`) is never read into memory for capture — the log entry gets a placeholder (`{"_coolhand_capture_skipped" => "non_json_content_type", ...}`) instead of the body. This is intentional: an opaque binary blob isn't human/LLM-readable in Coolhand's UI anyway.
54
+ - A JSON (or content-type-unset) body larger than the configured limit is replaced with a placeholder (`{"_coolhand_capture_skipped" => "body_too_large", ...}`) instead of being logged in full.
55
+
56
+ In both cases, only the *captured log entry* is affected — the real request to the LLM provider always sends the complete, untruncated body.
57
+
42
58
  ## Custom Intercept Addresses
43
59
 
44
60
  By default Coolhand captures requests to a built-in list of LLM API hosts (OpenAI, Anthropic, Google Gemini, ElevenLabs, GitHub Models, and more). To capture a custom endpoint — an internal proxy, a self-hosted model server, or a third-party gateway — override `intercept_addresses`:
@@ -57,3 +73,27 @@ end
57
73
  Setting `intercept_addresses` **replaces** the default list entirely, so include any default hosts you still need.
58
74
 
59
75
  The default list can be found in `Coolhand::Configuration::DEFAULT_INTERCEPT_ADDRESSES`.
76
+
77
+ Each entry is matched against the request's parsed host — an exact match, or a dot-boundary suffix match (`my-llm-proxy.internal` also matches `eu.my-llm-proxy.internal`) — case-insensitively. A single `*` in an entry matches exactly one host label, e.g. `bedrock-runtime.*.amazonaws.com`. Entries are never matched against the full URL, so query strings or paths that merely mention a host can't trigger a false match.
78
+
79
+ A small number of Google API endpoints use a colon-suffixed path (e.g. `.../gemini-pro:generateContent`) with no host of their own. Those live in the separate `intercept_path_patterns` setting (default: `Coolhand::Configuration::DEFAULT_INTERCEPT_PATH_PATTERNS`) and, unlike `intercept_addresses`, are only checked against the path of requests whose host is already `googleapis.com` or a subdomain of it — and only when `intercept_addresses` still includes at least one `googleapis.com` host. If you override `intercept_addresses` to a list with no Google hosts, `intercept_path_patterns` has no effect and Google API traffic is not captured at all.
80
+
81
+ `intercept_addresses` is a required allow-list — nothing is captured unless its host matches an entry here. Because of that, `config.intercept_addresses = []` is **not** a supported way to disable capture (it's ignored, with a warning logged, and the previous value is kept). To disable capture entirely, use `config.enabled = false` or `config.capture = false` instead — see [Capture Control](#capture-control) below.
82
+
83
+ ## Excluding API Patterns
84
+
85
+ `config.exclude_api_patterns` is a deny-list checked *after* `intercept_addresses` allows a request through — a match against the request's path is skipped and never forwarded as an `llm_request_log`. It defaults to `["/batchPredictionJobs/"]`, to suppress Vertex AI batch job management noise.
86
+
87
+ ```ruby
88
+ Coolhand.configure do |config|
89
+ config.exclude_api_patterns << "/myOperationalPath/" # extend the defaults
90
+ # config.exclude_api_patterns = ["/only_this/"] # replace the defaults entirely
91
+ # config.exclude_api_patterns = [] # disable exclusion entirely
92
+ end
93
+ ```
94
+
95
+ Unlike `intercept_addresses`, `exclude_api_patterns` genuinely supports `= []` — since it only narrows an allow-list that already passed, an empty deny-list just means "exclude nothing," which is safe.
96
+
97
+ ## Capture Control
98
+
99
+ `config.enabled` and `config.capture` are the supported ways to turn capture off entirely — see the Configuration Parameters table in the [README](../README.md#configuration-options).
data/docs/openai.md CHANGED
@@ -58,3 +58,34 @@ def webhook_secret
58
58
  end
59
59
  # ...existing code...
60
60
  ```
61
+
62
+ ## Replay protection
63
+
64
+ `Coolhand::OpenAi::WebhookValidator` rejects requests whose `webhook-timestamp` is more than **300 seconds** away from the current time, and rejects a `webhook-id` it has already seen within that window — this prevents a captured request (from a log aggregator, APM trace, or proxy log) from being replayed to re-trigger `Coolhand::OpenAi::BatchResultProcessor`.
65
+
66
+ Both are configurable:
67
+
68
+ ```ruby
69
+ Coolhand.configure do |config|
70
+ # Widen or narrow the timestamp tolerance window (seconds). Default: 300.
71
+ config.webhook_replay_tolerance_seconds = 600
72
+ end
73
+ ```
74
+
75
+ The default `webhook-id` dedup store is in-memory and per-process, so it only protects a single process — replays across multiple app processes/dynos within the tolerance window aren't caught by default. If you run more than one process, supply your own store via `config.webhook_id_store`; any object responding to `claim!(id, ttl_seconds)` works, e.g. one backed by `Rails.cache`. `claim!` must atomically check-and-record the id in one operation (not a separate read then write) — otherwise two requests racing on the same id can both "win" — so lean on your cache backend's compare-and-set primitive (`unless_exist:` below maps to Redis `SET NX` when using `RedisCacheStore`):
76
+
77
+ ```ruby
78
+ class RailsCacheWebhookIdStore
79
+ # Returns true if this is the first time `id` has been claimed (the
80
+ # write happened), false if it was already claimed within its TTL.
81
+ def claim!(id, ttl_seconds)
82
+ Rails.cache.write("coolhand:openai_webhook:#{id}", true, expires_in: ttl_seconds, unless_exist: true)
83
+ end
84
+ end
85
+
86
+ Coolhand.configure do |config|
87
+ config.webhook_id_store = RailsCacheWebhookIdStore.new
88
+ end
89
+ ```
90
+
91
+ Note: the `webhook-id` is claimed as soon as the signature and timestamp are confirmed valid — before your controller's `process_event` runs. If something downstream fails before the batch is actually handled (e.g. an unexpectedly-shaped payload), a same-id retry within the tolerance window will be rejected as a replay rather than reprocessed.
@@ -0,0 +1,218 @@
1
+ # Reading Templates (Search + Get)
2
+
3
+ `Coolhand::TemplateService` reads back the LLM request templates your logs are matched against.
4
+ It wraps two read-only endpoints:
5
+
6
+ | method | endpoint |
7
+ |---|---|
8
+ | `search_templates` | `GET /api/v2/llm_request_templates` |
9
+ | `get_template` | `GET /api/v2/llm_request_templates/{id}` |
10
+
11
+ Both require your **private** API key. The public key is write-only on this API and is rejected
12
+ exactly like an invalid one.
13
+
14
+ ```ruby
15
+ require "coolhand"
16
+
17
+ Coolhand.configure do |config|
18
+ config.api_key = ENV.fetch("COOLHAND_PRIVATE_API_KEY")
19
+ end
20
+
21
+ templates = Coolhand.template_service
22
+
23
+ result = templates.search_templates(search: "summar", status: "published")
24
+ result.templates.each { |template| puts "#{template[:name]} (#{template[:log_count]} logs)" }
25
+
26
+ detail = templates.get_template(result.templates.first[:id])
27
+ puts detail[:user_prompt_pattern]
28
+ ```
29
+
30
+ ## What this is not
31
+
32
+ This is **not** a port of the `search_templates` MCP tool, and the two do not agree:
33
+
34
+ - `log_count` here counts only directly-collected client logs — the same records
35
+ `GET /api/v2/llm_request_logs?template_id=…` returns. Evals, bakeoff comparisons and synthetic
36
+ logs are excluded, so this number is often lower than the MCP tool's.
37
+ - Templates whose workload has been archived are **returned** here, not hidden, so the list agrees
38
+ with `get_template`, which can always fetch such a template by id. Narrow with `workload_id`
39
+ instead.
40
+
41
+ Template *creation, update and deprecation* stay on the MCP surface. This REST surface is
42
+ read-only, and there is no version-history sub-resource.
43
+
44
+ ## `search_templates(search: nil, workload_id: nil, status: nil, include_deprecated: nil, include_system: nil, page: nil, per: nil)`
45
+
46
+ Search is a *parameter* on the list endpoint rather than a route of its own, so this is one method
47
+ rather than a list/search pair.
48
+
49
+ All filters are optional keyword arguments, and their names are the wire names — Ruby's convention
50
+ and this API's already agree, so there is no second spelling to translate through.
51
+
52
+ | keyword | type | notes |
53
+ |---|---|---|
54
+ | `search` | String | Case-insensitive **literal** substring match on the template name. `%` and `_` are escaped server-side and match themselves — do not escape them again. |
55
+ | `workload_id` | String | Workload hashid. One that does not decode, or that belongs to another client, is a `422` rather than an empty list. |
56
+ | `status` | String | `"draft"`, `"published"` or `"failure"`. Any other non-empty value is a `422` from the server; empty is treated as no filter. |
57
+ | `include_deprecated` | Boolean | Include templates with a non-null `deprecated_at`. Defaults to `false` server-side. |
58
+ | `include_system` | Boolean | Include the `Unmatched` / `Ignored API Calls` buckets. Defaults to `false` server-side. |
59
+ | `page` | Integer | 1-based. |
60
+ | `per` | Integer | Default 25, max 100, both enforced server-side. |
61
+
62
+ Two things it deliberately does **not** do:
63
+
64
+ - **There is no `client_id` keyword.** The client is always derived from the authenticating API key
65
+ and cannot be supplied, so passing one raises `ArgumentError` rather than reaching the wire.
66
+ - **`status` is not checked against a client-side allowlist.** The API definition enumerates the
67
+ values on the *query parameter*, but leaves the `status` field on the *response* an unconstrained
68
+ string. Sending an unrecognised value gets you the server's `422`; a status the server gains
69
+ later works without a gem release.
70
+
71
+ `per_page` is accepted on the wire as an alias for `per`. This gem only ever sends `per` — one knob
72
+ is enough.
73
+
74
+ ### Return value
75
+
76
+ A `Coolhand::TemplateSearchResult` — a `Struct`, so `#templates`, `#pagination`, `#to_h` and
77
+ pattern matching all work:
78
+
79
+ ```ruby
80
+ result = Coolhand.template_service.search_templates(include_system: true)
81
+
82
+ result.templates # => [{ id: "kp9npvc8qq2q", name: "Unmatched", ... }, ...]
83
+ result.pagination # => #<struct Coolhand::Pagination current_page=1, ...>
84
+ ```
85
+
86
+ **Rows are plain Hashes with Symbol keys**, the same shape `create_feedback` and `create_log`
87
+ already return. They are handed back exactly as the API rendered them rather than being copied into
88
+ a value object, so a field the server adds later reaches you instead of being silently dropped on
89
+ the way through. That is also why there is no `system_template?` predicate: read the wire field,
90
+ `template[:system_template]`.
91
+
92
+ Rows are ordered newest first (`created_at DESC`, with a primary-key tiebreaker so paging is stable
93
+ across requests). Each carries:
94
+
95
+ | field | type | notes |
96
+ |---|---|---|
97
+ | `:id` | String | Hashid, never the integer primary key. |
98
+ | `:name` | String | Never null, but may be blank on a draft. |
99
+ | `:status` | String or nil | `draft` / `published` / `failure`. |
100
+ | `:version` | String or nil | |
101
+ | `:group` | String or nil | `chat`, `user_prompt`, `user_prompt_with_system_prompt`, `embedding`, `other`. |
102
+ | `:workload_id` | String | Workload hashid; never null. |
103
+ | `:workload_name` | String | Never null. |
104
+ | `:system_template` | Boolean | True for the `Unmatched` / `Ignored API Calls` buckets. |
105
+ | `:deprecated_at` | String or nil | ISO-8601 UTC. Non-null means the template has been superseded. |
106
+ | `:log_count` | Integer | Directly-collected client logs only — see [What this is not](#what-this-is-not). |
107
+ | `:created_at` | String | ISO-8601 UTC. |
108
+ | `:updated_at` | String | ISO-8601 UTC. |
109
+
110
+ **Prompt patterns are not in the list.** They come from `get_template` only.
111
+
112
+ `pagination` is a `Coolhand::Pagination` built from the endpoint's `X-Page`, `X-Per-Page`,
113
+ `X-Total-Count` and `X-Total-Pages` response headers — never from the size of the array, which only
114
+ ever describes the page in hand. Unlike `/llm_request_logs` there is no `include_total` opt-out
115
+ here; the headers are always sent.
116
+
117
+ | field | type |
118
+ |---|---|
119
+ | `current_page` | Integer |
120
+ | `per_page` | Integer |
121
+ | `total_count` | Integer |
122
+ | `total_pages` | Integer |
123
+ | `has_next_page` | Boolean |
124
+ | `has_prev_page` | Boolean |
125
+
126
+ ### System templates and the empty default list
127
+
128
+ Every Coolhand client is created with two system buckets, `Unmatched` and `Ignored API Calls`. They
129
+ are hidden unless you pass `include_system: true`, so a client with no templates of its own gets an
130
+ empty array rather than those two rows. `Unmatched` is what you inspect when logs are misrouting:
131
+
132
+ ```ruby
133
+ unmatched = Coolhand.template_service
134
+ .search_templates(include_system: true, search: "unmatched")
135
+ .templates
136
+ .first
137
+
138
+ puts unmatched[:log_count]
139
+ ```
140
+
141
+ ## `get_template(id)`
142
+
143
+ `id` is the template hashid — the `:id` field from `search_templates`.
144
+
145
+ Returns a Hash with every list field above, **plus** the full untruncated regexes the list omits:
146
+
147
+ | field | type |
148
+ |---|---|
149
+ | `:user_prompt_pattern` | String or nil |
150
+ | `:system_prompt_pattern` | String or nil |
151
+
152
+ Unlike the list, this filters on nothing but client ownership: a deprecated or system template is
153
+ reachable by id with **no opt-in flag**, because inspecting one of those is the usual reason to
154
+ fetch a template directly.
155
+
156
+ ## Errors
157
+
158
+ The write methods in this gem (`create_feedback`, `create_log`, `send_llm_request_log`) log a
159
+ failure and return `nil` — instrumentation must never be the reason a host app falls over. **The
160
+ read methods are the opposite and raise**, because a caller that asked for data has to be able to
161
+ tell a `404` from a timeout from a genuinely empty result.
162
+
163
+ | raised | when |
164
+ |---|---|
165
+ | `Coolhand::HttpError` | The server answered with a non-2xx status. `#status` is the HTTP status code and `#body` the response body. |
166
+ | `Coolhand::Error` | No API key configured, a transport failure, a body that is not valid JSON, or a blank `id` passed to `get_template`. |
167
+
168
+ `Coolhand::HttpError` is a `Coolhand::Error`, so `rescue Coolhand::Error` catches both.
169
+
170
+ **Branch on `#status`, never on the message:**
171
+
172
+ ```ruby
173
+ begin
174
+ result = Coolhand.template_service.search_templates
175
+ rescue Coolhand::HttpError => e
176
+ case e.status
177
+ when 401 then warn "Private API key required — the public key cannot read"
178
+ when 422 then warn "Bad filter: #{e.body}"
179
+ when 504 then warn "Timed out aggregating log_count — narrow the query and retry"
180
+ else raise
181
+ end
182
+ end
183
+ ```
184
+
185
+ | status | meaning |
186
+ |---|---|
187
+ | `401` | Missing, invalid, or public API key. |
188
+ | `404` | `get_template` only. Unknown id, **or** one belonging to another client — existence is not disclosed, so this is never a `403`. |
189
+ | `422` | Unrecognised `status`, or a `workload_id` that does not decode or belongs to another client. |
190
+ | `504` | See below. |
191
+
192
+ ### `504` is expected, and retryable
193
+
194
+ `log_count` aggregates over `llm_request_logs`, so its cost scales with how many logs the matched
195
+ templates hold — the `Unmatched` bucket can hold every log that never matched a template. Every
196
+ query behind these responses is bounded by a 10-second statement timeout, and exceeding it returns
197
+ `504` rather than hanging. It reaches you as an `HttpError` with `status == 504`, not folded into a
198
+ generic server error, precisely so you can narrow and retry:
199
+
200
+ ```ruby
201
+ templates = Coolhand.template_service
202
+
203
+ begin
204
+ templates.search_templates(include_system: true)
205
+ rescue Coolhand::HttpError => e
206
+ raise unless e.status == 504
207
+
208
+ # Narrow the aggregate: one workload at a time, smaller pages.
209
+ templates.search_templates(include_system: true, workload_id: workload_id, per: 10)
210
+ end
211
+ ```
212
+
213
+ This is also why the read path waits far longer than the write path before giving up. A write
214
+ times out after 5 seconds because it runs inline in your app's own request. A read allows 60: the
215
+ server bounds each *statement* behind a response at 10 seconds, but one response runs several, so
216
+ a slow-but-working `include_system=true` call measured 7-15 seconds against a development database
217
+ while returning `200` every time. Giving up sooner would report a working endpoint as a transport
218
+ failure, and would pre-empt the `504` you are meant to see and retry.