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 +4 -4
- data/AGENTS.harness.md +139 -0
- data/CHANGELOG.md +31 -0
- data/README.md +30 -1
- data/docs/configuration.md +40 -0
- data/docs/openai.md +31 -0
- data/docs/template-search.md +218 -0
- data/lib/coolhand/api_service.rb +49 -15
- data/lib/coolhand/base_interceptor.rb +27 -17
- data/lib/coolhand/configuration.rb +37 -4
- data/lib/coolhand/default_exclude_api_patterns.yml +3 -2
- data/lib/coolhand/default_intercept_addresses.yml +10 -5
- data/lib/coolhand/default_intercept_path_patterns.yml +12 -0
- data/lib/coolhand/errors.rb +19 -0
- data/lib/coolhand/logger_service.rb +1 -1
- data/lib/coolhand/net_http_interceptor.rb +127 -20
- data/lib/coolhand/open_ai/batch_result_processor.rb +1 -1
- data/lib/coolhand/open_ai/webhook_id_store.rb +45 -0
- data/lib/coolhand/open_ai/webhook_validator.rb +44 -10
- data/lib/coolhand/pagination.rb +82 -0
- data/lib/coolhand/read_requests.rb +77 -0
- data/lib/coolhand/template_service.rb +76 -0
- data/lib/coolhand/version.rb +1 -1
- data/lib/coolhand.rb +9 -4
- metadata +10 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: a3c1e767ea245f56520ee634e48cd2f12c6dc06be79b3998537fea9e9b8ea9ff
|
|
4
|
+
data.tar.gz: aad0485d474aa1e3f796f2af30083304a1ed704ede8428ddc4b0c9a4ff32678e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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
|
|
data/docs/configuration.md
CHANGED
|
@@ -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.
|