coolhand 0.8.0 → 0.9.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/CHANGELOG.md +20 -0
- data/README.md +27 -2
- data/Rakefile +7 -0
- data/docs/declared_workloads.md +156 -0
- data/docs/openai.md +4 -0
- data/lib/coolhand/api_service.rb +5 -1
- data/lib/coolhand/base_interceptor.rb +3 -1
- data/lib/coolhand/configuration.rb +14 -1
- data/lib/coolhand/declarable.rb +178 -0
- data/lib/coolhand/declared_context.rb +106 -0
- data/lib/coolhand/log_service.rb +1 -7
- data/lib/coolhand/logger_service.rb +10 -18
- data/lib/coolhand/net_http_interceptor.rb +3 -2
- data/lib/coolhand/open_ai/webhook_validator.rb +27 -15
- data/lib/coolhand/rails_auto_declare.rb +55 -0
- data/lib/coolhand/railtie.rb +11 -0
- data/lib/coolhand/read_query.rb +14 -0
- data/lib/coolhand/response_warnings.rb +39 -0
- data/lib/coolhand/template_service.rb +26 -10
- data/lib/coolhand/version.rb +1 -1
- data/lib/coolhand/workload_service.rb +12 -8
- data/lib/coolhand/write_requests.rb +14 -0
- data/lib/coolhand.rb +34 -12
- metadata +8 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 48857d802183f91a163c82c3e1dd89ce34ac7a8f1f3d1079c5ae6d5d594ad300
|
|
4
|
+
data.tar.gz: a852465d5b9dced73580eff8c81f45093a97c48c79a66579cff27c2dbedc7cbe
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 0d4e6bc5e9ef29365afaba5b9c4a4edbadb552f77268adfe27ac6276f23cd44e4c75fe6df85b1ddef8bdf25d99d88204a46a127dcce7c3a2183edc09e5019508
|
|
7
|
+
data.tar.gz: ee192c55d280b47abc3f6a9af7f2ddd48d4966493955cee5614557feeea2d87d71a28c16f488fee4dc9fddd916b88fc9ee98621e058e3ad0de80431fc6bd871c
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.9.0] - 2026-10-18
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- **Declare the workload and template of captured LLM calls** ([#153](https://github.com/Coolhand-Labs/coolhand-ruby/pull/153)) — `Coolhand.with_context(reporter:, workload:, template:) { ... }` stamps every LLM call captured inside the block with the workload and template it belongs to, sent as `llm_request_log.declared`. The context is snapshotted on the calling thread, so it survives `async_logging`. Nested blocks override per field (innermost wins). `Coolhand::Declarable` (`coolhand_workload`, `coolhand_declare`, `coolhand_declare_all`) declares it once on a class, inherited by subclasses, and opt-in `config.auto_declare = [:active_job, :controller]` does the same for Rails jobs and controller actions (the gem still has no Rails dependency). `Coolhand.workload_service.upsert` and `Coolhand.template_service.upsert` create or update records by key with your **private** API key and raise `Coolhand::HttpError` on failure, and `warnings` returned with a logged request (e.g. the public-key cap) are now logged. Keys longer than 255 characters, with surrounding whitespace or a leading, trailing or doubled `/`, and a template `key` with no workload raise `ArgumentError` up front, because the server would reject and drop the whole log. Older servers ignore `declared`. See [Declared Workloads](docs/declared_workloads.md).
|
|
14
|
+
- `bin/live-examples` (also `bundle exec rake examples:live`) runs every `examples/*.rb` against real provider keys and prints one `PASS` / `SKIP` / `FAIL` line per script ([#155](https://github.com/Coolhand-Labs/coolhand-ruby/pull/155)). Development tooling only: `bin/` and `examples/` are not packaged in the gem. See [Examples](examples/README.md).
|
|
15
|
+
|
|
16
|
+
### Changed
|
|
17
|
+
- The `🔗 Net::HTTP interceptor patched` log line is no longer printed when the interceptor is installed ([#154](https://github.com/Coolhand-Labs/coolhand-ruby/pull/154)). **Migration note:** anything that matched on that line in log output should stop doing so; use `Coolhand::NetHttpInterceptor.patched?` instead.
|
|
18
|
+
- Internal de-duplication with no behavior change ([#156](https://github.com/Coolhand-Labs/coolhand-ruby/pull/156)): list reads share `ReadQuery#fetch_list`, `LoggerService` shares one webhook-rejection path, `OpenAi::WebhookValidator` shares one enforce-or-allow path (same error messages and same strict/lenient behavior by `Rails.env`), and `Coolhand.with_capture` / `without_capture` share one override helper. `contract/` (declared, intentional duplication) is excluded from the gem package.
|
|
19
|
+
- CI: added the `exhale` duplication and complexity gate ([#156](https://github.com/Coolhand-Labs/coolhand-ruby/pull/156)), and bumped `ruby/setup-ruby` to 1.329.0 ([#150](https://github.com/Coolhand-Labs/coolhand-ruby/pull/150)) and `actions/upload-artifact` to 7.0.2 ([#151](https://github.com/Coolhand-Labs/coolhand-ruby/pull/151)).
|
|
20
|
+
|
|
21
|
+
### Fixed
|
|
22
|
+
- `OpenAi::WebhookValidator` now accepts a `webhook-signature` header carrying several space-separated signatures (`v1,sigA v1,sigB`) and passes if any `v1,` entry matches ([#161](https://github.com/Coolhand-Labs/coolhand-ruby/pull/161)). Previously the whole remainder after `v1,` was compared as one value, so a legitimate webhook sent during secret rotation was rejected with a 401. Every candidate is compared in constant time, non-`v1` entries are ignored, a header that is not valid UTF-8 is rejected rather than raising, and the timestamp and replay-id checks still run only after a match. Single-signature headers behave as before. See [OpenAI Batch Webhook Handler](docs/openai.md#signature-header-and-secret-rotation).
|
|
23
|
+
|
|
24
|
+
### Security
|
|
25
|
+
- Warnings returned in a Coolhand log response are now sanitized before they are printed ([#162](https://github.com/Coolhand-Labs/coolhand-ruby/pull/162)): entries are coerced with `to_s`, invalid UTF-8 is scrubbed, control characters and line separators become a space, invisible Unicode format characters are removed, entries over 500 characters are truncated, and at most 5 are printed per response. Well-formed warnings print as before.
|
|
26
|
+
|
|
27
|
+
### Documentation
|
|
28
|
+
- `docs/declared_workloads.md` and the README now state that `Coolhand.with_context`, `with_capture` and `without_capture` are fiber-local, so work started in a new Fiber or Async task inside the block is not covered, and show opening the block inside the Fiber instead ([#160](https://github.com/Coolhand-Labs/coolhand-ruby/pull/160)).
|
|
29
|
+
|
|
10
30
|
## [0.8.0] - 2026-10-09
|
|
11
31
|
|
|
12
32
|
### Added
|
data/README.md
CHANGED
|
@@ -132,6 +132,29 @@ links.unlink_feedback('optimizationHashid', link[:id])
|
|
|
132
132
|
|
|
133
133
|
For batching, error semantics, and details, see [Linking Feedback →](docs/feedback-links.md).
|
|
134
134
|
|
|
135
|
+
## Declaring Workloads and Templates
|
|
136
|
+
|
|
137
|
+
Tell Coolhand which workload and template an LLM call belongs to, instead of waiting for it to be matched. Calls captured inside the block carry the declared identity:
|
|
138
|
+
|
|
139
|
+
```ruby
|
|
140
|
+
Coolhand.with_context(reporter: 'my-app', workload: { key: 'Parser#call' }, template: { key: 'Parser#call/summarize' }) do
|
|
141
|
+
client.chat(...) # captured as usual
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
Coolhand.workload_service.upsert(key: 'Parser#call', name: 'Parser') # private key; create or rename
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
Or declare it once on the class and let every capture inside it pick it up, subclasses included:
|
|
148
|
+
|
|
149
|
+
```ruby
|
|
150
|
+
class ApplicationService
|
|
151
|
+
include Coolhand::Declarable
|
|
152
|
+
coolhand_declare_all # each public method declares "ClassName#method"
|
|
153
|
+
end
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
For nesting rules, class-level declarations, Rails jobs and controllers, upserts, and public-key limits, see [Declared Workloads →](docs/declared_workloads.md).
|
|
157
|
+
|
|
135
158
|
## Rails Integration
|
|
136
159
|
|
|
137
160
|
### Configuration
|
|
@@ -212,6 +235,7 @@ end
|
|
|
212
235
|
| `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) |
|
|
213
236
|
| `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) |
|
|
214
237
|
| `async_logging` | Boolean | `true` | Send logs to Coolhand from a background thread so a slow Coolhand API never delays your LLM calls. Set to `false` to send inline — see [Advanced Configuration](docs/configuration.md#asynchronous-logging) |
|
|
238
|
+
| `auto_declare` | Array | `[]` | Rails only: `:active_job` and/or `:controller` to declare a workload and template for LLM calls made from jobs and controller actions — see [Declared Workloads](docs/declared_workloads.md#rails-auto-declare) |
|
|
215
239
|
|
|
216
240
|
## Usage Examples
|
|
217
241
|
|
|
@@ -273,7 +297,7 @@ Coolhand.without_capture do
|
|
|
273
297
|
end
|
|
274
298
|
```
|
|
275
299
|
|
|
276
|
-
Both methods are thread-safe. Blocks nest correctly — the innermost block takes precedence, and the outer setting is restored when the block exits.
|
|
300
|
+
Both methods are thread-safe. Blocks nest correctly — the innermost block takes precedence, and the outer setting is restored when the block exits. The override is fiber-local, so it does not follow into new Threads, Fibers or Async tasks started inside the block; see [Threads and background logging](docs/declared_workloads.md#threads-and-background-logging).
|
|
277
301
|
|
|
278
302
|
## Logging Inbound Webhooks
|
|
279
303
|
|
|
@@ -464,7 +488,7 @@ The monitor handles errors gracefully:
|
|
|
464
488
|
- Failed API logging attempts are logged to console but don't interrupt your application
|
|
465
489
|
- Invalid API keys will be reported but won't crash your app
|
|
466
490
|
- Network issues are handled with appropriate error messages
|
|
467
|
-
- The read methods (`search_templates`, `get_template`, `search_workloads`, `search_logs`, `get_log`)
|
|
491
|
+
- The read methods (`search_templates`, `get_template`, `search_workloads`, `search_logs`, `get_log`), the feedback-link methods and the `upsert` methods are the exception — they raise `Coolhand::Error` (`Coolhand::HttpError` for non-2xx responses); see [Reading Templates →](docs/template-search.md), [Reading Workloads →](docs/workload-search.md), [Reading Logs →](docs/log-search.md), [Linking Feedback →](docs/feedback-links.md) and [Declared Workloads →](docs/declared_workloads.md)
|
|
468
492
|
|
|
469
493
|
## Documentation
|
|
470
494
|
|
|
@@ -474,6 +498,7 @@ The monitor handles errors gracefully:
|
|
|
474
498
|
- **[Reading Workloads](docs/workload-search.md)** — List workloads with optional cost and performance metrics over a rolling or explicit time window
|
|
475
499
|
- **[Reading Logs](docs/log-search.md)** — Search logs with per-log cost, filter and sort by cost, and fetch one log with its cost breakdown
|
|
476
500
|
- **[Linking Feedback](docs/feedback-links.md)** — Link feedback to an optimization as evidence, singly or in bulk, using the private API key
|
|
501
|
+
- **[Declared Workloads](docs/declared_workloads.md)** — Declare the workload and template of captured LLM calls with `Coolhand.with_context`, and create or rename them with `upsert`
|
|
477
502
|
- **[Anthropic Integration](docs/anthropic.md)** — Official and community Anthropic Ruby gems, streaming, dual gem handling, and troubleshooting
|
|
478
503
|
- **[ElevenLabs Integration](docs/elevenlabs.md)** — Webhook capture, feedback submission, and Rails integration
|
|
479
504
|
- **[OpenAI Batch Webhook Handler](docs/openai.md)** — Handle OpenAI batch job completion events via webhook interception
|
data/Rakefile
CHANGED
|
@@ -9,4 +9,11 @@ require "rubocop/rake_task"
|
|
|
9
9
|
|
|
10
10
|
RuboCop::RakeTask.new
|
|
11
11
|
|
|
12
|
+
namespace :examples do
|
|
13
|
+
desc "Run every live script in examples/ against real provider keys (loads keys via zsh -ic)"
|
|
14
|
+
task :live do
|
|
15
|
+
sh RbConfig.ruby, File.expand_path("bin/live-examples", __dir__)
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
12
19
|
task default: %i[spec rubocop]
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
# Declaring workloads and templates
|
|
2
|
+
|
|
3
|
+
By default Coolhand works out which workload and template an LLM call belongs to by matching the log against known templates. If your code already knows (a service object, a prompt library, an adapter), declare it instead. A declared log skips matching entirely and is filed under the workload and template you named.
|
|
4
|
+
|
|
5
|
+
There are two parts:
|
|
6
|
+
|
|
7
|
+
- `Coolhand.with_context` stamps the LLM calls the gem captures automatically.
|
|
8
|
+
- `Coolhand.workload_service.upsert` and `Coolhand.template_service.upsert` create or update workloads and templates explicitly. They need your **private** API key.
|
|
9
|
+
|
|
10
|
+
## Declaring on captured calls
|
|
11
|
+
|
|
12
|
+
```ruby
|
|
13
|
+
Coolhand.with_context(
|
|
14
|
+
reporter: "squishling",
|
|
15
|
+
workload: { key: "Parser#call", name: "Parser#call", description: "Parses incoming tickets" },
|
|
16
|
+
template: { key: "Parser#call/squish:L2", name: "Parser#call · squish L2" }
|
|
17
|
+
) do
|
|
18
|
+
client.chat(...) # captured as usual, and sent with the declared identity
|
|
19
|
+
end
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
| Field | Accepts | Notes |
|
|
23
|
+
|---|---|---|
|
|
24
|
+
| `reporter` | String | Who is making the declaration, e.g. the library or app. Used when a record is first created. |
|
|
25
|
+
| `workload` | Hash: `id`, `key`, `name`, `description` | Needs an `id` or a `key`. Other keys are ignored. |
|
|
26
|
+
| `template` | Hash: `id`, `key`, `name` | Needs an `id` or a `key`. Other keys are ignored. |
|
|
27
|
+
|
|
28
|
+
- `id` refers to an existing record. `key` is a stable string of your choosing; Coolhand finds or creates the record with that key. On a template, `id` wins over `key`.
|
|
29
|
+
- `name`, `description` and `reporter` are used only when a record is **first created**. Declaring never renames or re-attributes an existing record; use `upsert` for that. A display name that is already taken gets a numeric suffix, and names such as "Unmatched" are reserved.
|
|
30
|
+
- A declaration with only a `workload` gets a default template keyed `<workload key>/default`. A template given by `key` needs a workload alongside it, either in the same block or an enclosing one.
|
|
31
|
+
- Keys are at most 255 characters, printable, and cannot start or end with whitespace or `/` or contain `//`. `#`, `:` and `·` are fine. Descriptions are cut to 1000 characters.
|
|
32
|
+
- Keys must be stable, so never put a request id or timestamp in one: each distinct key creates a record.
|
|
33
|
+
- A `workload` or `template` that is not a Hash, has neither `id` nor `key`, has an invalid key, or a template `key` with no workload raises `ArgumentError` before your block runs. The server would answer these with a `422` and **not save the log**.
|
|
34
|
+
- An unknown `id` (or one from another client) gets the same `422` and the log is not saved, so prefer `key`.
|
|
35
|
+
|
|
36
|
+
### Nesting
|
|
37
|
+
|
|
38
|
+
Blocks nest. Each field (`reporter`, `workload`, `template`) is taken from the innermost block that sets it, and fields an inner block leaves out are inherited (with the one exception below):
|
|
39
|
+
|
|
40
|
+
```ruby
|
|
41
|
+
Coolhand.with_context(reporter: "my-app", workload: { key: "Parser#call" }) do
|
|
42
|
+
Coolhand.with_context(template: { key: "Parser#call/summarize" }) do
|
|
43
|
+
# reporter: "my-app", workload: Parser#call, template: Parser#call/summarize
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
A template belongs to a workload, so an inner block that declares a `workload` without a `template` does not inherit the outer block's template. Declare both together when you change workload.
|
|
49
|
+
|
|
50
|
+
A `workload` or `template` Hash is replaced as a whole, not merged key by key. An inner `workload: { key: "b" }` inside an outer `workload: { id: "a" }` declares only `b`, rather than a mix of `id` and `key`.
|
|
51
|
+
|
|
52
|
+
### Threads and background logging
|
|
53
|
+
|
|
54
|
+
The context is thread-local, like `Coolhand.with_capture`. It is also fiber-local, so it does not carry into threads, fibers, `Enumerator#next` blocks or async tasks you start inside the block; calls made there are captured without a declaration. The identity is read on the thread that made the LLM call, before the log is queued, so it survives `async_logging` (the default).
|
|
55
|
+
|
|
56
|
+
This matters for Fiber-scheduler concurrency such as [Async](https://github.com/socketry/async). Nothing raises: the call is logged, just without a workload or template.
|
|
57
|
+
|
|
58
|
+
```ruby
|
|
59
|
+
Coolhand.with_context(workload: { key: "Parser#call" }) do
|
|
60
|
+
Async do
|
|
61
|
+
client.chat(...) # runs in a new Fiber: captured with no declared workload
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
`Coolhand.with_capture` and `Coolhand.without_capture` work the same way, so a `without_capture` block does not suppress calls made from a Fiber started inside it, and with `config.capture = false` a `with_capture` block does not enable them. To cover work that runs in another Fiber, open the block inside that Fiber:
|
|
67
|
+
|
|
68
|
+
```ruby
|
|
69
|
+
Async do
|
|
70
|
+
Coolhand.with_context(workload: { key: "Parser#call" }) do
|
|
71
|
+
client.chat(...) # captured with the declared workload
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
### Public keys
|
|
77
|
+
|
|
78
|
+
Declarations made with a public key are capped per client at 200 workloads and 1,000 templates. Past the cap, Coolhand ignores the declaration, ingests the log normally, and returns a warning, which the gem prints unless `config.silent` is on. Server-supplied warnings are sanitized before printing (control and invisible characters removed, each entry cut at 500 characters, at most 5 printed per response). Declarations from a private key are not capped.
|
|
79
|
+
|
|
80
|
+
Older Coolhand servers ignore `declared`, so it is safe to use against them.
|
|
81
|
+
|
|
82
|
+
## Declaring on a class
|
|
83
|
+
|
|
84
|
+
Wrapping every call site in `with_context` does not suit passive capture. Include `Coolhand::Declarable` and declare the identity once, on the class. Wrapped methods run inside `with_context`, so everything above (nesting, precedence, async) applies unchanged.
|
|
85
|
+
|
|
86
|
+
```ruby
|
|
87
|
+
class ApplicationService
|
|
88
|
+
include Coolhand::Declarable
|
|
89
|
+
|
|
90
|
+
coolhand_workload reporter: "my-app"
|
|
91
|
+
coolhand_declare_all
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
class Parser < ApplicationService
|
|
95
|
+
def call(ticket) = client.chat(...) # workload "Parser#call", template "Parser#call"
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
class Summariser < ApplicationService
|
|
99
|
+
coolhand_workload key: "Summariser" # one workload for every method; templates stay per method
|
|
100
|
+
|
|
101
|
+
def call(ticket) = client.chat(...) # workload "Summariser", template "Summariser#call"
|
|
102
|
+
end
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
- `coolhand_declare :call, :other` wraps just those methods, whether they are defined before or after the line. `template:` takes a key or a `{ key:, name: }` Hash instead of the default `"ClassName#method"`: `coolhand_declare :call, template: "Parser#call/summarise"`.
|
|
106
|
+
- `coolhand_declare_all` wraps every public instance method defined after it, in the class and its subclasses, so put it at the top of the class body. Private and protected methods are left alone, however they are made private (`private def`, `private :name`, or a `private` section), as are attribute readers and writers and object-protocol methods such as `to_s`, `==` and `method_missing`. A method you name in `coolhand_declare` is wrapped regardless.
|
|
107
|
+
- The key uses the **runtime** class, so a method inherited from `ApplicationService` declares as `Parser#call` when called on a `Parser`. Anonymous classes are not declared.
|
|
108
|
+
- `coolhand_workload(key:, name:, description:, reporter:)` sets class-level defaults and is inherited by subclasses, field by field, with the nearest class winning. Without a `key`, each method gets its own workload, `ClassName#method`. `name` and `description` need a `key`, since one name shared by per-method workloads would collide.
|
|
109
|
+
- Services that call each other nest: the innermost wrapped method wins, and it brings its own template (see [Nesting](#nesting)).
|
|
110
|
+
- It only works where the LLM call runs on the same thread and fiber as the wrapped method (see [Threads and background logging](#threads-and-background-logging)).
|
|
111
|
+
|
|
112
|
+
## Rails auto-declare
|
|
113
|
+
|
|
114
|
+
For Rails apps, Coolhand can declare jobs and controller actions without touching their classes. It is off by default, because every distinct job and action becomes a workload.
|
|
115
|
+
|
|
116
|
+
```ruby
|
|
117
|
+
Coolhand.configure do |config|
|
|
118
|
+
config.api_key = ENV.fetch("COOLHAND_API_KEY")
|
|
119
|
+
config.auto_declare = [:active_job, :controller]
|
|
120
|
+
end
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
| Target | Wraps | Declares |
|
|
124
|
+
|---|---|---|
|
|
125
|
+
| `:active_job` | `around_perform` on `ActiveJob::Base` | `SummariseJob#perform`, reporter `rails` |
|
|
126
|
+
| `:controller` | `around_action` on `ActionController::Base` and `::API` | `UsersController#show`, reporter `rails` |
|
|
127
|
+
|
|
128
|
+
Coolhand must be loaded after Rails (the default with `Bundler.require` in a Rails app), otherwise the hooks are never registered. They are installed after your initializers have run, so `Coolhand.configure` can come before or after anything else. Anything declared inside the job or action (a `Declarable` service, a `with_context` block) takes precedence over the job or action declaration. With a public API key the per-client declaration cap applies (see [Public keys](#public-keys)), so enable only the targets you want as workloads.
|
|
129
|
+
|
|
130
|
+
## Creating and renaming explicitly
|
|
131
|
+
|
|
132
|
+
```ruby
|
|
133
|
+
Coolhand.configure { |config| config.api_key = ENV.fetch("COOLHAND_PRIVATE_API_KEY") }
|
|
134
|
+
|
|
135
|
+
workload = Coolhand.workload_service.upsert(
|
|
136
|
+
key: "Parser#call", name: "Parser", description: "Parses incoming tickets", reporter: "my-app"
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
Coolhand.template_service.upsert(
|
|
140
|
+
key: "Parser#call/summarize",
|
|
141
|
+
workload: { key: "Parser#call" }, # or { id: workload[:id] }
|
|
142
|
+
name: "Parser · summarize",
|
|
143
|
+
reporter: "my-app"
|
|
144
|
+
)
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
- Both are `PUT .../by_key/<key>`: they create the record if the key is new and update it otherwise. The server answers `201` on create and `200` on update; both return the record as a Hash.
|
|
148
|
+
- The key never changes. Calling `upsert` with the same key and a new `name` renames the record, and an update changes only the fields you send.
|
|
149
|
+
- A template needs a `workload` (`{ id: }` or `{ key: }`, sent to the server as `workload_id` / `workload_key`) when it is new. For an existing template, naming a different workload is a `422`.
|
|
150
|
+
- A key may contain `/`, `#`, `:` and other characters; the gem escapes it for the URL.
|
|
151
|
+
|
|
152
|
+
## Errors
|
|
153
|
+
|
|
154
|
+
`upsert` raises, unlike the logging writes. A non-2xx response raises `Coolhand::HttpError` whose `status` is the HTTP code (`401` for a missing or public key, `422` for an invalid key, a taken name, a missing or unknown workload, or a key held by a deprecated template, `504` if the server timed out). A blank key, or one made of `.` / `..` path segments, and transport failures raise `Coolhand::Error`; a `workload` that is not a Hash raises `ArgumentError`.
|
|
155
|
+
|
|
156
|
+
`config.debug_mode` does not suppress `upsert`: it changes server state, so it is always sent.
|
data/docs/openai.md
CHANGED
|
@@ -59,6 +59,10 @@ end
|
|
|
59
59
|
# ...existing code...
|
|
60
60
|
```
|
|
61
61
|
|
|
62
|
+
## Signature header and secret rotation
|
|
63
|
+
|
|
64
|
+
The `webhook-signature` header may carry several space-separated signatures (`v1,sigA v1,sigB`), as senders produce while a webhook secret is being rotated. `Coolhand::OpenAi::WebhookValidator` accepts the request if any `v1,` entry matches your secret, comparing each in constant time and ignoring entries with any other version prefix. The timestamp and replay checks below still run, and only after a signature matches.
|
|
65
|
+
|
|
62
66
|
## Replay protection
|
|
63
67
|
|
|
64
68
|
`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`.
|
data/lib/coolhand/api_service.rb
CHANGED
|
@@ -6,12 +6,14 @@ require "json"
|
|
|
6
6
|
require_relative "collector"
|
|
7
7
|
require_relative "errors"
|
|
8
8
|
require_relative "read_requests"
|
|
9
|
+
require_relative "response_warnings"
|
|
9
10
|
|
|
10
11
|
module Coolhand
|
|
11
12
|
class ApiService
|
|
12
13
|
# The GET half of this class. See Coolhand::ReadRequests for why reads raise where writes
|
|
13
14
|
# log and return nil.
|
|
14
15
|
include ReadRequests
|
|
16
|
+
include ResponseWarnings
|
|
15
17
|
|
|
16
18
|
# Caps how much of a failed response body is interpolated into a log line or an exception
|
|
17
19
|
# message. Without it an oversized body from a proxy or gateway becomes the message.
|
|
@@ -36,7 +38,9 @@ module Coolhand
|
|
|
36
38
|
log JSON.pretty_generate(sanitize_payload_for_json(payload))
|
|
37
39
|
nil
|
|
38
40
|
else
|
|
39
|
-
send_request(payload, "✅ Successfully sent request metadata")
|
|
41
|
+
result = send_request(payload, "✅ Successfully sent request metadata")
|
|
42
|
+
log_response_warnings(result)
|
|
43
|
+
result
|
|
40
44
|
end
|
|
41
45
|
end
|
|
42
46
|
|
|
@@ -149,7 +149,8 @@ module Coolhand
|
|
|
149
149
|
end
|
|
150
150
|
|
|
151
151
|
def send_complete_request_log(request_id:, method:, url:, request_headers:, request_body:, response_headers:,
|
|
152
|
-
response_body:, status_code:, start_time:, end_time:, duration_ms:, is_streaming:, source_api: nil, model: nil
|
|
152
|
+
response_body:, status_code:, start_time:, end_time:, duration_ms:, is_streaming:, source_api: nil, model: nil,
|
|
153
|
+
declared: nil)
|
|
153
154
|
raw_request = {
|
|
154
155
|
id: request_id,
|
|
155
156
|
timestamp: start_time.iso8601,
|
|
@@ -168,6 +169,7 @@ module Coolhand
|
|
|
168
169
|
raw_request[:model] = model if Coolhand.required_field?(model)
|
|
169
170
|
|
|
170
171
|
request_data = { raw_request: raw_request }
|
|
172
|
+
request_data[:declared] = declared if declared && !declared.empty?
|
|
171
173
|
|
|
172
174
|
LogQueue.submit do
|
|
173
175
|
Coolhand::ApiService.new.send_llm_request_log(request_data)
|
|
@@ -30,7 +30,7 @@ module Coolhand
|
|
|
30
30
|
|
|
31
31
|
attr_accessor :api_key, :environment, :silent, :debug_mode, :capture, :exclude_api_patterns, :enabled,
|
|
32
32
|
:max_captured_body_bytes, :webhook_replay_tolerance_seconds, :webhook_id_store, :async_logging
|
|
33
|
-
attr_reader :intercept_addresses, :intercept_path_patterns, :base_url
|
|
33
|
+
attr_reader :intercept_addresses, :intercept_path_patterns, :base_url, :auto_declare
|
|
34
34
|
|
|
35
35
|
def initialize
|
|
36
36
|
# Set defaults
|
|
@@ -45,6 +45,7 @@ module Coolhand
|
|
|
45
45
|
@exclude_api_patterns = DEFAULT_EXCLUDE_API_PATTERNS.dup
|
|
46
46
|
@enabled = true
|
|
47
47
|
@async_logging = true
|
|
48
|
+
@auto_declare = []
|
|
48
49
|
@max_captured_body_bytes = DEFAULT_MAX_CAPTURED_BODY_BYTES
|
|
49
50
|
@webhook_replay_tolerance_seconds = 300
|
|
50
51
|
@webhook_id_store = Coolhand::OpenAi::WebhookIdStore.new
|
|
@@ -74,6 +75,18 @@ module Coolhand
|
|
|
74
75
|
@intercept_path_patterns = value.is_a?(Array) ? value : [value]
|
|
75
76
|
end
|
|
76
77
|
|
|
78
|
+
# Rails entry points to declare a workload/template for automatically: any of
|
|
79
|
+
# RailsAutoDeclare::TARGETS (:active_job, :controller). Off by default. See docs/declared_workloads.md.
|
|
80
|
+
def auto_declare=(value)
|
|
81
|
+
targets = Array(value).map { |target| target.to_s.to_sym }
|
|
82
|
+
unknown = targets - RailsAutoDeclare::TARGETS
|
|
83
|
+
unless unknown.empty?
|
|
84
|
+
raise Error, "auto_declare supports #{RailsAutoDeclare::TARGETS.inspect}, got #{unknown.inspect}"
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
@auto_declare = targets.uniq
|
|
88
|
+
end
|
|
89
|
+
|
|
77
90
|
def base_url=(value)
|
|
78
91
|
stripped = value&.sub(%r{/+\z}, "")
|
|
79
92
|
raise Error, BASE_URL_ERROR_MSG unless stripped.nil? || valid_base_url?(stripped)
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Coolhand
|
|
4
|
+
# Declares the workload/template of the LLM calls a class makes, once, on the class, so passive
|
|
5
|
+
# capture picks them up without a `Coolhand.with_context` block at every call site. Wrapped methods
|
|
6
|
+
# run inside `with_context`, so nesting and precedence are exactly those documented in
|
|
7
|
+
# docs/declared_workloads.md. Settings are inherited by subclasses.
|
|
8
|
+
#
|
|
9
|
+
# class ApplicationService
|
|
10
|
+
# include Coolhand::Declarable
|
|
11
|
+
# coolhand_workload reporter: "my-app"
|
|
12
|
+
# coolhand_declare_all
|
|
13
|
+
# end
|
|
14
|
+
#
|
|
15
|
+
# class Parser < ApplicationService
|
|
16
|
+
# def call = client.chat(...) # workload "Parser#call", template "Parser#call"
|
|
17
|
+
# end
|
|
18
|
+
module Declarable
|
|
19
|
+
def self.included(base)
|
|
20
|
+
raise ArgumentError, "Coolhand::Declarable can only be included in a class" unless base.is_a?(Class)
|
|
21
|
+
|
|
22
|
+
base.extend(ClassMethods)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Never an LLM call site, and wrapping them would tax every call or declare a junk workload.
|
|
26
|
+
SKIPPED_METHODS = %i[method_missing respond_to? respond_to_missing? == === eql? equal? hash to_s inspect
|
|
27
|
+
to_str to_ary to_proc coerce <=>].freeze
|
|
28
|
+
ATTRIBUTE_MACROS = %w[attr attr_reader attr_writer attr_accessor].freeze
|
|
29
|
+
private_constant :SKIPPED_METHODS, :ATTRIBUTE_MACROS
|
|
30
|
+
|
|
31
|
+
module ClassMethods
|
|
32
|
+
# Class-level defaults, inherited field by field (the nearest class that sets a field wins). With
|
|
33
|
+
# no `key`, each wrapped method declares its own workload, "ClassName#method"; a `key` makes every
|
|
34
|
+
# method share one workload. The class name is the runtime class, so an inherited method declares
|
|
35
|
+
# under the subclass.
|
|
36
|
+
def coolhand_workload(key: nil, name: nil, description: nil, reporter: nil)
|
|
37
|
+
workload = DeclaredContext.normalize_part(:workload, { key: key, name: name, description: description }.compact,
|
|
38
|
+
DeclaredContext::WORKLOAD_FIELDS)
|
|
39
|
+
@coolhand_settings = { workload: workload, reporter: DeclaredContext.normalize_reporter(reporter) }.compact
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Wraps the named instance methods (now, or when they are defined later). `template:` is a key or a
|
|
43
|
+
# `{ key:, name: }` Hash; by default the template key is "ClassName#method".
|
|
44
|
+
def coolhand_declare(*names, template: nil)
|
|
45
|
+
# Checked here, at class definition, so a bad value never surfaces inside the host's method.
|
|
46
|
+
template = DeclaredContext.normalize_part(:template, template.is_a?(String) ? { key: template } : template,
|
|
47
|
+
DeclaredContext::TEMPLATE_FIELDS)
|
|
48
|
+
names.flatten.each do |name|
|
|
49
|
+
name = name.to_sym
|
|
50
|
+
(@coolhand_declared ||= {})[name] = { template: template }
|
|
51
|
+
coolhand_wrap(name) if method_defined?(name) || private_method_defined?(name)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Wraps every public instance method defined in this class and its subclasses after this call, so
|
|
56
|
+
# put it at the top of the class body. Private and protected methods, attribute readers/writers
|
|
57
|
+
# and object-protocol methods (see SKIPPED_METHODS) are left alone; `coolhand_declare` wraps one of
|
|
58
|
+
# those if you name it.
|
|
59
|
+
def coolhand_declare_all
|
|
60
|
+
@coolhand_declare_all = true
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# @api private
|
|
64
|
+
def coolhand_identity(method_name)
|
|
65
|
+
key = "#{name}##{method_name}"
|
|
66
|
+
template = coolhand_template_for(method_name)
|
|
67
|
+
{
|
|
68
|
+
reporter: coolhand_setting(:reporter),
|
|
69
|
+
workload: { key: key }.merge(coolhand_setting(:workload) || {}),
|
|
70
|
+
template: template || { key: key }
|
|
71
|
+
}
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# @api private
|
|
75
|
+
def coolhand_declare_call?(method_name)
|
|
76
|
+
return false if name.nil? || !Coolhand.configuration.enabled
|
|
77
|
+
return false unless DeclaredContext.valid_key?("#{name}##{method_name}")
|
|
78
|
+
|
|
79
|
+
public_method_defined?(method_name) || !coolhand_declaration(method_name).nil?
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# @api private
|
|
83
|
+
def coolhand_own_state
|
|
84
|
+
[@coolhand_settings, @coolhand_declared, @coolhand_declare_all]
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
private
|
|
88
|
+
|
|
89
|
+
def method_added(method_name)
|
|
90
|
+
super
|
|
91
|
+
coolhand_wrap(method_name) if !@coolhand_wrapping && coolhand_wrap?(method_name)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def coolhand_wrap?(method_name)
|
|
95
|
+
return true if coolhand_declaration(method_name)
|
|
96
|
+
|
|
97
|
+
public_method_defined?(method_name) && !SKIPPED_METHODS.include?(method_name) &&
|
|
98
|
+
coolhand_declare_all? && !attribute_definition?
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# True while `attr_reader`/`attr_writer`/`attr_accessor` is what is defining the method. Read from
|
|
102
|
+
# the stack rather than by overriding those macros, which would lose the class body's
|
|
103
|
+
# `private`/`protected` default visibility. A window of frames, not one fixed depth, so a
|
|
104
|
+
# `method_added` override that calls `super` does not hide the macro.
|
|
105
|
+
def attribute_definition?
|
|
106
|
+
caller_locations(1, 8)&.any? { |frame| ATTRIBUTE_MACROS.include?(frame.base_label) } || false
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def coolhand_declare_all?
|
|
110
|
+
coolhand_each_class { |klass| return true if klass.coolhand_own_state[2] }
|
|
111
|
+
false
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Redefines the method in the same class, rather than prepending a wrapper, so a later
|
|
115
|
+
# `private :name` (or `private def name`) applies to the wrapper like it would to the original.
|
|
116
|
+
def coolhand_wrap(method_name)
|
|
117
|
+
original = instance_method(method_name)
|
|
118
|
+
wrappers = (@coolhand_wrappers ||= {})
|
|
119
|
+
return if wrappers[method_name] == original
|
|
120
|
+
|
|
121
|
+
visibility = coolhand_visibility(method_name)
|
|
122
|
+
@coolhand_wrapping = true
|
|
123
|
+
define_method(method_name) do |*args, **kwargs, &block|
|
|
124
|
+
klass = self.class
|
|
125
|
+
next original.bind_call(self, *args, **kwargs, &block) unless klass.coolhand_declare_call?(method_name)
|
|
126
|
+
|
|
127
|
+
Coolhand.with_context(**klass.coolhand_identity(method_name)) do
|
|
128
|
+
original.bind_call(self, *args, **kwargs, &block)
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
send(visibility, method_name)
|
|
132
|
+
wrappers[method_name] = instance_method(method_name)
|
|
133
|
+
ensure
|
|
134
|
+
@coolhand_wrapping = false
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Includes inherited methods: wrapping one a parent made private must not make it public here.
|
|
138
|
+
def coolhand_visibility(method_name)
|
|
139
|
+
return :private if private_method_defined?(method_name)
|
|
140
|
+
return :protected if protected_method_defined?(method_name)
|
|
141
|
+
|
|
142
|
+
:public
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# Yields this class and its superclasses that include Declarable, nearest first. A loop rather
|
|
146
|
+
# than a collected Array, since the lookups below run on every wrapped call.
|
|
147
|
+
def coolhand_each_class
|
|
148
|
+
klass = self
|
|
149
|
+
while klass.respond_to?(:coolhand_own_state)
|
|
150
|
+
yield klass
|
|
151
|
+
klass = klass.superclass
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def coolhand_declaration(method_name)
|
|
156
|
+
coolhand_nearest(1, method_name)
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def coolhand_template_for(method_name)
|
|
160
|
+
coolhand_declaration(method_name)&.fetch(:template)
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def coolhand_setting(field)
|
|
164
|
+
coolhand_nearest(0, field)
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# The value for `key` in the nearest class whose own state (slot `slot` of `coolhand_own_state`)
|
|
168
|
+
# sets it, or nil.
|
|
169
|
+
def coolhand_nearest(slot, key)
|
|
170
|
+
coolhand_each_class do |klass|
|
|
171
|
+
state = klass.coolhand_own_state[slot]
|
|
172
|
+
return state[key] if state&.key?(key)
|
|
173
|
+
end
|
|
174
|
+
nil
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
end
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Coolhand
|
|
4
|
+
# Ambient workload/template identity for captured calls, set with {Coolhand.with_context}. The
|
|
5
|
+
# interceptor snapshots {current} on the calling thread before a log is queued, because the
|
|
6
|
+
# background log worker does not inherit thread-locals. See docs/declared_workloads.md.
|
|
7
|
+
module DeclaredContext
|
|
8
|
+
THREAD_KEY = :coolhand_declared_context
|
|
9
|
+
|
|
10
|
+
# The fields the server accepts under `declared.workload` / `declared.template`; anything else
|
|
11
|
+
# is dropped here rather than sent.
|
|
12
|
+
WORKLOAD_FIELDS = %i[id key name description].freeze
|
|
13
|
+
TEMPLATE_FIELDS = %i[id key name].freeze
|
|
14
|
+
|
|
15
|
+
module_function
|
|
16
|
+
|
|
17
|
+
# A frozen Hash, or nil when no context is active.
|
|
18
|
+
def current
|
|
19
|
+
Thread.current[THREAD_KEY]
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def clear!
|
|
23
|
+
Thread.current[THREAD_KEY] = nil
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Each field is replaced wholesale by the innermost block that sets it. Hashes are not merged
|
|
27
|
+
# key by key: `{ id: x }` merged with `{ key: y }` would declare both, and the server prefers `id`.
|
|
28
|
+
# A template belongs to a workload, so a block that declares a workload without a template does
|
|
29
|
+
# not inherit the outer one; it would pair the new workload with a template from another.
|
|
30
|
+
def with(reporter: nil, workload: nil, template: nil)
|
|
31
|
+
previous = current
|
|
32
|
+
declared = {
|
|
33
|
+
reporter: normalize_reporter(reporter),
|
|
34
|
+
workload: normalize_part(:workload, workload, WORKLOAD_FIELDS),
|
|
35
|
+
template: normalize_part(:template, template, TEMPLATE_FIELDS)
|
|
36
|
+
}.compact
|
|
37
|
+
inherited = previous || {}
|
|
38
|
+
inherited = inherited.except(:template) if declared.key?(:workload) && !declared.key?(:template)
|
|
39
|
+
merged = inherited.merge(declared)
|
|
40
|
+
ensure_template_has_workload!(merged)
|
|
41
|
+
Thread.current[THREAD_KEY] = merged.empty? ? nil : merged.freeze
|
|
42
|
+
yield
|
|
43
|
+
ensure
|
|
44
|
+
Thread.current[THREAD_KEY] = previous
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def normalize_reporter(reporter)
|
|
48
|
+
return nil if reporter.nil?
|
|
49
|
+
|
|
50
|
+
reporter.to_s
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Mirrors the server's key rules: at most 255 characters, no control or invisible characters, no
|
|
54
|
+
# leading or trailing whitespace, and no leading, trailing or doubled "/". The server answers a
|
|
55
|
+
# bad key with a 422 that rejects the whole log, so it is checked here, before the call is made.
|
|
56
|
+
MAX_KEY_LENGTH = 255
|
|
57
|
+
KEY_FORMAT = %r{
|
|
58
|
+
\A(?!/)(?!.*//)
|
|
59
|
+
[^\p{Cntrl}\p{Cf}[:space:]]
|
|
60
|
+
(?:[^\p{Cntrl}\p{Cf}]*[^\p{Cntrl}\p{Cf}[:space:]])?
|
|
61
|
+
(?<!/)\z
|
|
62
|
+
}x
|
|
63
|
+
|
|
64
|
+
def valid_key?(key)
|
|
65
|
+
key.is_a?(String) && key.length <= MAX_KEY_LENGTH && KEY_FORMAT.match?(key)
|
|
66
|
+
rescue ArgumentError, EncodingError
|
|
67
|
+
false # a string with a broken or incompatible encoding is not a valid key
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# A template given by key, with no workload to create it in, is a 422 that drops the log. A
|
|
71
|
+
# template `id` refers to an existing record and needs no workload.
|
|
72
|
+
def ensure_template_has_workload!(declared)
|
|
73
|
+
template = declared[:template]
|
|
74
|
+
return if template.nil? || template.key?(:id) || declared.key?(:workload)
|
|
75
|
+
|
|
76
|
+
raise ArgumentError, "template with a :key needs a workload; declare one in this or an enclosing block"
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Validated up front: the server answers a non-object `workload`/`template` with a 422 for the
|
|
80
|
+
# whole log, which would drop the call from Coolhand rather than just its identity.
|
|
81
|
+
def normalize_part(name, value, fields)
|
|
82
|
+
return nil if value.nil?
|
|
83
|
+
raise ArgumentError, "#{name} must be a Hash, got #{value.class}" unless value.is_a?(Hash)
|
|
84
|
+
|
|
85
|
+
part = value.each_with_object({}) do |(k, v), acc|
|
|
86
|
+
field = k.respond_to?(:to_sym) ? k.to_sym : nil
|
|
87
|
+
next unless fields.include?(field) && !v.nil?
|
|
88
|
+
# A blank id refers to nothing and would 422 the log, so it counts as not given.
|
|
89
|
+
next if field == :id && v.to_s.strip.empty?
|
|
90
|
+
|
|
91
|
+
acc[field] = v.to_s.dup.freeze
|
|
92
|
+
end
|
|
93
|
+
return nil if part.empty?
|
|
94
|
+
|
|
95
|
+
if part.key?(:key) && !valid_key?(part[:key])
|
|
96
|
+
raise ArgumentError, "#{name} key must be at most #{MAX_KEY_LENGTH} printable characters with no " \
|
|
97
|
+
"surrounding whitespace and no leading, trailing or doubled \"/\""
|
|
98
|
+
end
|
|
99
|
+
unless part.key?(:id) || part.key?(:key)
|
|
100
|
+
raise ArgumentError, "#{name} needs an :id or a :key, got #{value.keys.inspect}"
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
part.freeze
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
data/lib/coolhand/log_service.rb
CHANGED
|
@@ -47,13 +47,7 @@ module Coolhand
|
|
|
47
47
|
per: per
|
|
48
48
|
}.compact
|
|
49
49
|
|
|
50
|
-
logs,
|
|
51
|
-
raise Error, "#{ERROR_NOUN} response was not a JSON array" unless logs.is_a?(Array)
|
|
52
|
-
|
|
53
|
-
LogSearchResult.new(
|
|
54
|
-
logs: logs,
|
|
55
|
-
pagination: Pagination.from_headers(response, items: logs, page: page, per: per)
|
|
56
|
-
).freeze
|
|
50
|
+
fetch_list(query, LogSearchResult, :logs, page: page, per: per)
|
|
57
51
|
end
|
|
58
52
|
|
|
59
53
|
# The full log, with `cost` and `cost_breakdown`. Both are nil when the log has no tokens or its
|
|
@@ -15,24 +15,8 @@ module Coolhand
|
|
|
15
15
|
# Helper method for forwarding webhook data to Coolhand
|
|
16
16
|
def forward_webhook(webhook_body:, source:, event_type: nil, headers: {}, **options)
|
|
17
17
|
# Validate required parameters
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
if Coolhand.configuration.silent
|
|
21
|
-
puts "COOLHAND WARNING: #{error_msg}"
|
|
22
|
-
return false
|
|
23
|
-
else
|
|
24
|
-
raise ArgumentError, error_msg
|
|
25
|
-
end
|
|
26
|
-
end
|
|
27
|
-
|
|
28
|
-
unless Coolhand.required_field?(source)
|
|
29
|
-
error_msg = "source is required and cannot be nil or empty"
|
|
30
|
-
if Coolhand.configuration.silent
|
|
31
|
-
puts "COOLHAND WARNING: #{error_msg}"
|
|
32
|
-
return false
|
|
33
|
-
else
|
|
34
|
-
raise ArgumentError, error_msg
|
|
35
|
-
end
|
|
18
|
+
{ webhook_body: webhook_body, source: source }.each do |name, value|
|
|
19
|
+
return reject_webhook("#{name} is required and cannot be nil or empty") unless Coolhand.required_field?(value)
|
|
36
20
|
end
|
|
37
21
|
|
|
38
22
|
# Auto-generate required fields unless provided
|
|
@@ -55,6 +39,14 @@ module Coolhand
|
|
|
55
39
|
|
|
56
40
|
private
|
|
57
41
|
|
|
42
|
+
# Silent mode warns and drops the webhook; otherwise the caller gets the error.
|
|
43
|
+
def reject_webhook(error_msg)
|
|
44
|
+
raise ArgumentError, error_msg unless Coolhand.configuration.silent
|
|
45
|
+
|
|
46
|
+
puts "COOLHAND WARNING: #{error_msg}"
|
|
47
|
+
false
|
|
48
|
+
end
|
|
49
|
+
|
|
58
50
|
def build_webhook_url(source, event_type)
|
|
59
51
|
base = "webhook://#{source}"
|
|
60
52
|
event_type ? "#{base}/#{event_type}" : base
|
|
@@ -46,13 +46,12 @@ module Coolhand
|
|
|
46
46
|
Net::HTTPResponse.prepend(ResponseInterceptor)
|
|
47
47
|
|
|
48
48
|
@patched = true
|
|
49
|
-
Coolhand.log "🔗 Net::HTTP interceptor patched"
|
|
50
49
|
rescue StandardError
|
|
51
50
|
# Roll back this call's hold — it never actually took effect, so it must not count
|
|
52
51
|
# toward the refcount or a legitimate later unpatch! would underflow against it. This
|
|
53
52
|
# branch only runs when @patched was false on entry (see `next if @patched` above), so
|
|
54
53
|
# forcing it back to false here is correct regardless of which line above raised —
|
|
55
|
-
# including a failure in the
|
|
54
|
+
# including a failure in the second prepend, after the first already succeeded.
|
|
56
55
|
@patch_count -= 1
|
|
57
56
|
@patched = false
|
|
58
57
|
raise
|
|
@@ -139,7 +138,9 @@ module Coolhand
|
|
|
139
138
|
end_time = Time.now
|
|
140
139
|
duration_ms = ((end_time - start_time) * 1000).round(2)
|
|
141
140
|
|
|
141
|
+
# Read here on the calling thread: the log worker thread has no access to our thread-locals.
|
|
142
142
|
BaseInterceptor.send_complete_request_log(
|
|
143
|
+
declared: DeclaredContext.current,
|
|
143
144
|
request_id: request_id,
|
|
144
145
|
method: req.method,
|
|
145
146
|
url: url,
|
|
@@ -7,6 +7,9 @@ require_relative "webhook_id_store"
|
|
|
7
7
|
module Coolhand
|
|
8
8
|
module OpenAi
|
|
9
9
|
class WebhookValidator
|
|
10
|
+
SIGNATURE_VERSION_PREFIX = "v1,"
|
|
11
|
+
private_constant :SIGNATURE_VERSION_PREFIX
|
|
12
|
+
|
|
10
13
|
attr_reader :request, :errors, :payload
|
|
11
14
|
|
|
12
15
|
def initialize(request, webhook_secret)
|
|
@@ -41,27 +44,25 @@ module Coolhand
|
|
|
41
44
|
def payload_valid?
|
|
42
45
|
return true if @payload
|
|
43
46
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
"not in development/test allowlist)"
|
|
47
|
-
Rails.logger.error(@errors.last)
|
|
48
|
-
false
|
|
49
|
-
else
|
|
50
|
-
Rails.logger.warn("Empty webhook payload - allowing in #{Rails.env} environment")
|
|
51
|
-
true
|
|
52
|
-
end
|
|
47
|
+
enforce_or_allow("Empty webhook payload",
|
|
48
|
+
"Empty webhook payload - allowing in #{Rails.env} environment")
|
|
53
49
|
end
|
|
54
50
|
|
|
55
51
|
def validate_in_non_production_env
|
|
52
|
+
enforce_or_allow("OpenAI webhook secret not configured",
|
|
53
|
+
"Webhook Secret is not configured - skipping signature verification in #{Rails.env}")
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Outside the development/test allowlist the webhook is rejected with `problem`; inside it the
|
|
57
|
+
# check is skipped with a warning.
|
|
58
|
+
def enforce_or_allow(problem, allow_warning)
|
|
56
59
|
if should_enforce_strict_validation?
|
|
57
|
-
@errors << "
|
|
60
|
+
@errors << "#{problem} - rejecting webhook (Rails.env=#{Rails.env.inspect} " \
|
|
58
61
|
"not in development/test allowlist)"
|
|
59
62
|
Rails.logger.error(@errors.last)
|
|
60
63
|
false
|
|
61
64
|
else
|
|
62
|
-
Rails.logger.warn(
|
|
63
|
-
"Webhook Secret is not configured - skipping signature verification in #{Rails.env}"
|
|
64
|
-
)
|
|
65
|
+
Rails.logger.warn(allow_warning)
|
|
65
66
|
true
|
|
66
67
|
end
|
|
67
68
|
end
|
|
@@ -99,8 +100,7 @@ module Coolhand
|
|
|
99
100
|
expected_signature = calculate_expected_signature(secret_bytes, signed_payload)
|
|
100
101
|
|
|
101
102
|
# An empty key (a bare "whsec_" secret) makes the HMAC forgeable by anyone, so never accept it.
|
|
102
|
-
signature_valid = !secret_bytes.empty? &&
|
|
103
|
-
secure_compare(webhook_signature[3..], expected_signature)
|
|
103
|
+
signature_valid = !secret_bytes.empty? && signature_matches?(webhook_signature, expected_signature)
|
|
104
104
|
|
|
105
105
|
unless signature_valid
|
|
106
106
|
@errors << "OpenAI webhook signature verification failed"
|
|
@@ -114,6 +114,18 @@ module Coolhand
|
|
|
114
114
|
true
|
|
115
115
|
end
|
|
116
116
|
|
|
117
|
+
# Standard Webhooks allows several space-separated signatures (e.g. during secret rotation);
|
|
118
|
+
# accept the request if any v1 entry matches. Every candidate is compared (`map`, not an
|
|
119
|
+
# `any?` block) so there is no early exit on the first match.
|
|
120
|
+
# Split as binary so a header that isn't valid UTF-8 is rejected rather than raising.
|
|
121
|
+
def signature_matches?(webhook_signature, expected_signature)
|
|
122
|
+
candidates = webhook_signature.b.split.select { |entry| entry.start_with?(SIGNATURE_VERSION_PREFIX) }
|
|
123
|
+
results = candidates.map do |entry|
|
|
124
|
+
secure_compare(entry.delete_prefix(SIGNATURE_VERSION_PREFIX), expected_signature)
|
|
125
|
+
end
|
|
126
|
+
results.any?
|
|
127
|
+
end
|
|
128
|
+
|
|
117
129
|
# Checked only after the signature is confirmed valid, so an
|
|
118
130
|
# unsigned/forged request can't poison the id-dedup store (or fail a
|
|
119
131
|
# freshness check) and DoS a later legitimate webhook with the same id.
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Coolhand
|
|
4
|
+
# Opt-in (`config.auto_declare`) declaration of the workload and template for LLM calls made from a
|
|
5
|
+
# Rails job or controller action, keyed "JobClass#perform" / "ControllerClass#action". Installed from
|
|
6
|
+
# {Railtie} once the app has configured Coolhand. Kept free of any Rails require: it only touches
|
|
7
|
+
# ActiveSupport and ActiveJob when a target is enabled, and only through their own load hooks.
|
|
8
|
+
module RailsAutoDeclare
|
|
9
|
+
REPORTER = "rails"
|
|
10
|
+
TARGETS = %i[active_job controller].freeze
|
|
11
|
+
|
|
12
|
+
@installed = []
|
|
13
|
+
|
|
14
|
+
module_function
|
|
15
|
+
|
|
16
|
+
def install(targets = Coolhand.configuration.auto_declare)
|
|
17
|
+
TARGETS.each do |target|
|
|
18
|
+
next unless targets.include?(target) && !@installed.include?(target)
|
|
19
|
+
|
|
20
|
+
@installed << target
|
|
21
|
+
send(:"hook_#{target}")
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def hook_active_job
|
|
26
|
+
ActiveSupport.on_load(:active_job) do
|
|
27
|
+
around_perform do |job, block|
|
|
28
|
+
Coolhand::RailsAutoDeclare.declare(job.class, "perform", &block)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def hook_controller
|
|
34
|
+
ActiveSupport.on_load(:action_controller) do
|
|
35
|
+
around_action do |controller, block|
|
|
36
|
+
Coolhand::RailsAutoDeclare.declare(controller.class, controller.action_name, &block)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Runs the block inside a declared context. Anonymous classes have no stable key, so they are left
|
|
42
|
+
# undeclared rather than named after an object id.
|
|
43
|
+
def declare(klass, method_name, &block)
|
|
44
|
+
key = "#{klass.name}##{method_name}"
|
|
45
|
+
return yield if klass.name.nil? || !Coolhand.configuration.enabled || !DeclaredContext.valid_key?(key)
|
|
46
|
+
|
|
47
|
+
Coolhand.with_context(reporter: REPORTER, workload: { key: key }, template: { key: key }, &block)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Testing-only: forget which targets were hooked.
|
|
51
|
+
def reset!
|
|
52
|
+
@installed.clear
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Coolhand
|
|
4
|
+
# Loaded by lib/coolhand.rb only when Rails is present, so the gem keeps no Rails dependency.
|
|
5
|
+
class Railtie < ::Rails::Railtie
|
|
6
|
+
# After the app's initializers, so `config.auto_declare` has been set by `Coolhand.configure`.
|
|
7
|
+
config.after_initialize do
|
|
8
|
+
Coolhand::RailsAutoDeclare.install
|
|
9
|
+
end
|
|
10
|
+
end
|
|
11
|
+
end
|
data/lib/coolhand/read_query.rb
CHANGED
|
@@ -4,6 +4,7 @@ require "date"
|
|
|
4
4
|
require "time"
|
|
5
5
|
require "uri"
|
|
6
6
|
require_relative "errors"
|
|
7
|
+
require_relative "pagination"
|
|
7
8
|
|
|
8
9
|
module Coolhand
|
|
9
10
|
# URL building shared by the read services. Everything goes through `URI.encode_www_form`, so a
|
|
@@ -17,6 +18,19 @@ module Coolhand
|
|
|
17
18
|
uri
|
|
18
19
|
end
|
|
19
20
|
|
|
21
|
+
# GETs a list endpoint and wraps the JSON array and its pagination headers in `result_class`,
|
|
22
|
+
# keyed by `rows_key` (the field the result struct names its rows by).
|
|
23
|
+
def fetch_list(query, result_class, rows_key, page:, per:)
|
|
24
|
+
noun = self.class::ERROR_NOUN
|
|
25
|
+
rows, response = get_json_with_headers(list_url(query), noun)
|
|
26
|
+
raise Error, "#{noun} response was not a JSON array" unless rows.is_a?(Array)
|
|
27
|
+
|
|
28
|
+
result_class.new(
|
|
29
|
+
rows_key => rows,
|
|
30
|
+
pagination: Pagination.from_headers(response, items: rows, page: page, per: per)
|
|
31
|
+
).freeze
|
|
32
|
+
end
|
|
33
|
+
|
|
20
34
|
def resource_url(id, blank_message, query = {})
|
|
21
35
|
raise Error, blank_message unless id.is_a?(String)
|
|
22
36
|
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Coolhand
|
|
4
|
+
# Prints the `warnings` a Coolhand log response carries, for {ApiService}.
|
|
5
|
+
#
|
|
6
|
+
# Warnings come from the response body and are written to the host app's stdout, so they are
|
|
7
|
+
# untrusted: each is coerced to a string, stripped of control characters and length-capped, and
|
|
8
|
+
# the number printed per response is bounded.
|
|
9
|
+
module ResponseWarnings
|
|
10
|
+
MAX_WARNING_LENGTH = 500
|
|
11
|
+
MAX_WARNINGS = 5
|
|
12
|
+
|
|
13
|
+
protected
|
|
14
|
+
|
|
15
|
+
# The server ingests a log normally but ignores a declared workload/template it cannot honour
|
|
16
|
+
# (e.g. over the public-key cap), and says so here.
|
|
17
|
+
def log_response_warnings(result)
|
|
18
|
+
warnings = result.is_a?(Hash) ? Array(result[:warnings]) : []
|
|
19
|
+
warnings.first(MAX_WARNINGS).each { |warning| log "⚠️ Coolhand: #{sanitize_warning(warning)}" }
|
|
20
|
+
|
|
21
|
+
omitted = warnings.size - MAX_WARNINGS
|
|
22
|
+
log "⚠️ Coolhand: ... and #{omitted} more warning(s) omitted" if omitted.positive?
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
# Control characters and line/paragraph separators are replaced with a space, not dropped: a
|
|
28
|
+
# newline would forge a log line and ESC starts a terminal escape sequence. Unicode format
|
|
29
|
+
# characters (bidi overrides, zero-width) are invisible and can reorder or hide text in a
|
|
30
|
+
# terminal, so they are removed outright.
|
|
31
|
+
def sanitize_warning(warning)
|
|
32
|
+
text = warning.to_s.dup.force_encoding("UTF-8").scrub
|
|
33
|
+
text = text.gsub(/\p{Cf}+/, "").gsub(/[[:cntrl:]\p{Zl}\p{Zp}]+/, " ").strip
|
|
34
|
+
return text if text.length <= MAX_WARNING_LENGTH
|
|
35
|
+
|
|
36
|
+
"#{text[0, MAX_WARNING_LENGTH]}... [truncated]"
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -3,18 +3,20 @@
|
|
|
3
3
|
require_relative "api_service"
|
|
4
4
|
require_relative "pagination"
|
|
5
5
|
require_relative "read_query"
|
|
6
|
+
require_relative "write_requests"
|
|
6
7
|
|
|
7
8
|
module Coolhand
|
|
8
9
|
# Rows stay plain Symbol-keyed Hashes so a field the server adds later is not dropped in transit.
|
|
9
10
|
TemplateSearchResult = Struct.new(:templates, :pagination, keyword_init: true)
|
|
10
11
|
|
|
11
|
-
#
|
|
12
|
-
# rejected like an invalid one.
|
|
12
|
+
# Reads templates and upserts them by key. Requires the client's **private** key — the public key is
|
|
13
|
+
# write-only here and is rejected like an invalid one.
|
|
13
14
|
#
|
|
14
15
|
# Not a port of the MCP `search_templates` tool and does not agree with its `log_count`.
|
|
15
|
-
# See docs/template-search.md.
|
|
16
|
+
# See docs/template-search.md and docs/declared_workloads.md.
|
|
16
17
|
class TemplateService < ApiService
|
|
17
18
|
include ReadQuery
|
|
19
|
+
include WriteRequests
|
|
18
20
|
|
|
19
21
|
ERROR_NOUN = "Template"
|
|
20
22
|
BLANK_ID_MESSAGE = "get_template: id must be a non-empty template hashid"
|
|
@@ -39,13 +41,7 @@ module Coolhand
|
|
|
39
41
|
per: per
|
|
40
42
|
}.compact
|
|
41
43
|
|
|
42
|
-
templates,
|
|
43
|
-
raise Error, "#{ERROR_NOUN} response was not a JSON array" unless templates.is_a?(Array)
|
|
44
|
-
|
|
45
|
-
TemplateSearchResult.new(
|
|
46
|
-
templates: templates,
|
|
47
|
-
pagination: Pagination.from_headers(response, items: templates, page: page, per: per)
|
|
48
|
-
).freeze
|
|
44
|
+
fetch_list(query, TemplateSearchResult, :templates, page: page, per: per)
|
|
49
45
|
end
|
|
50
46
|
|
|
51
47
|
# Adds `user_prompt_pattern` / `system_prompt_pattern`, which the list omits, and unlike the
|
|
@@ -56,5 +52,25 @@ module Coolhand
|
|
|
56
52
|
**window_params(since, binding.local_variable_get(:until)) }.compact
|
|
57
53
|
get_json(resource_url(id, BLANK_ID_MESSAGE, query), ERROR_NOUN)
|
|
58
54
|
end
|
|
55
|
+
|
|
56
|
+
# Creates the template with this `key`, or updates the existing one. The key is immutable; a new
|
|
57
|
+
# `name` renames the display name. `workload` (`{ id: }` or `{ key: }`, the latter find-or-created)
|
|
58
|
+
# is needed only when the template is new; an existing one keeps its workload. Returns the template
|
|
59
|
+
# Hash. Raises {Coolhand::HttpError} on a non-2xx response. See docs/declared_workloads.md.
|
|
60
|
+
def upsert(key:, workload: nil, name: nil, reporter: nil)
|
|
61
|
+
body = { name: name, reporter: reporter, **workload_params(workload) }.compact
|
|
62
|
+
request_json(:put, by_key_url("upsert", key), ERROR_NOUN, body: body)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
private
|
|
66
|
+
|
|
67
|
+
def workload_params(workload)
|
|
68
|
+
return {} if workload.nil?
|
|
69
|
+
unless workload.is_a?(Hash)
|
|
70
|
+
raise ArgumentError, "upsert: workload must be a Hash with :id or :key, got #{workload.class}"
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
{ workload_id: workload[:id] || workload["id"], workload_key: workload[:key] || workload["key"] }
|
|
74
|
+
end
|
|
59
75
|
end
|
|
60
76
|
end
|
data/lib/coolhand/version.rb
CHANGED
|
@@ -3,15 +3,17 @@
|
|
|
3
3
|
require_relative "api_service"
|
|
4
4
|
require_relative "pagination"
|
|
5
5
|
require_relative "read_query"
|
|
6
|
+
require_relative "write_requests"
|
|
6
7
|
|
|
7
8
|
module Coolhand
|
|
8
9
|
# Rows stay plain Symbol-keyed Hashes so a field the server adds later is not dropped in transit.
|
|
9
10
|
WorkloadSearchResult = Struct.new(:workloads, :pagination, keyword_init: true)
|
|
10
11
|
|
|
11
|
-
#
|
|
12
|
-
# See docs/workload-search.md.
|
|
12
|
+
# Lists workloads and upserts them by key. Requires the client's **private** key. There is no per-id
|
|
13
|
+
# endpoint. See docs/workload-search.md and docs/declared_workloads.md.
|
|
13
14
|
class WorkloadService < ApiService
|
|
14
15
|
include ReadQuery
|
|
16
|
+
include WriteRequests
|
|
15
17
|
|
|
16
18
|
ERROR_NOUN = "Workload"
|
|
17
19
|
|
|
@@ -34,13 +36,15 @@ module Coolhand
|
|
|
34
36
|
per: per
|
|
35
37
|
}.compact
|
|
36
38
|
|
|
37
|
-
workloads,
|
|
38
|
-
|
|
39
|
+
fetch_list(query, WorkloadSearchResult, :workloads, page: page, per: per)
|
|
40
|
+
end
|
|
39
41
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
42
|
+
# Creates the workload with this `key`, or updates the existing one. The key is immutable; a new
|
|
43
|
+
# `name` renames the display name. Returns the workload Hash (the server answers 201 on create,
|
|
44
|
+
# 200 on update). Raises {Coolhand::HttpError} on a non-2xx response. See docs/declared_workloads.md.
|
|
45
|
+
def upsert(key:, name: nil, description: nil, reporter: nil)
|
|
46
|
+
body = { name: name, description: description, reporter: reporter }.compact
|
|
47
|
+
request_json(:put, by_key_url("upsert", key), ERROR_NOUN, body: body)
|
|
44
48
|
end
|
|
45
49
|
end
|
|
46
50
|
end
|
|
@@ -13,6 +13,8 @@ module Coolhand
|
|
|
13
13
|
# `api_key`, `apply_headers` and `format_error_body` from there and the shared HTTP helpers from
|
|
14
14
|
# {ReadRequests}.
|
|
15
15
|
module WriteRequests
|
|
16
|
+
DOT_SEGMENTS = [".", ".."].freeze
|
|
17
|
+
|
|
16
18
|
protected
|
|
17
19
|
|
|
18
20
|
# Returns the parsed body, or nil for an empty one (204).
|
|
@@ -26,6 +28,18 @@ module Coolhand
|
|
|
26
28
|
response.body.to_s.strip.empty? ? nil : parse_json_body(response.body, noun)
|
|
27
29
|
end
|
|
28
30
|
|
|
31
|
+
# `<endpoint>/by_key/<key>` for the upsert routes, which are globs: a key like "Parser#call/squish:L2"
|
|
32
|
+
# keeps its slashes and each segment is escaped, so `#` or `?` in a key cannot retarget the request.
|
|
33
|
+
def by_key_url(method_name, key)
|
|
34
|
+
trimmed = key.is_a?(String) ? key.strip : ""
|
|
35
|
+
segments = trimmed.split("/", -1)
|
|
36
|
+
if trimmed.empty? || segments.any? { |segment| segment.empty? || DOT_SEGMENTS.include?(segment) }
|
|
37
|
+
raise Error, "#{method_name}: key must be a non-empty string without empty or dot path segments"
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
URI.parse("#{api_endpoint}/by_key/#{segments.map { |segment| escape_path_segment(segment) }.join('/')}")
|
|
41
|
+
end
|
|
42
|
+
|
|
29
43
|
private
|
|
30
44
|
|
|
31
45
|
def perform_write(verb, url, noun, body)
|
data/lib/coolhand.rb
CHANGED
|
@@ -10,6 +10,9 @@ require_relative "coolhand/errors"
|
|
|
10
10
|
require_relative "coolhand/version"
|
|
11
11
|
require_relative "coolhand/configuration"
|
|
12
12
|
require_relative "coolhand/collector"
|
|
13
|
+
require_relative "coolhand/declared_context"
|
|
14
|
+
require_relative "coolhand/declarable"
|
|
15
|
+
require_relative "coolhand/rails_auto_declare"
|
|
13
16
|
require_relative "coolhand/log_queue"
|
|
14
17
|
require_relative "coolhand/base_interceptor"
|
|
15
18
|
require_relative "coolhand/net_http_interceptor"
|
|
@@ -21,6 +24,8 @@ require_relative "coolhand/optimization_feedback_link_service"
|
|
|
21
24
|
require_relative "coolhand/workload_service"
|
|
22
25
|
require_relative "coolhand/log_service"
|
|
23
26
|
require_relative "coolhand/webhook_interceptor"
|
|
27
|
+
# Only when Rails is already loaded; the gem has no Rails dependency.
|
|
28
|
+
require_relative "coolhand/railtie" if defined?(Rails::Railtie)
|
|
24
29
|
|
|
25
30
|
# The main module for the Coolhand gem.
|
|
26
31
|
# It provides the configuration interface and initializes the patching.
|
|
@@ -35,6 +40,7 @@ module Coolhand
|
|
|
35
40
|
def reset_configuration!
|
|
36
41
|
@configuration = Configuration.new
|
|
37
42
|
Thread.current[:coolhand_capture_override] = nil
|
|
43
|
+
DeclaredContext.clear!
|
|
38
44
|
end
|
|
39
45
|
|
|
40
46
|
# Provides a block to configure the gem.
|
|
@@ -74,20 +80,25 @@ module Coolhand
|
|
|
74
80
|
end
|
|
75
81
|
end
|
|
76
82
|
|
|
77
|
-
def without_capture
|
|
78
|
-
|
|
79
|
-
Thread.current[:coolhand_capture_override] = false
|
|
80
|
-
yield
|
|
81
|
-
ensure
|
|
82
|
-
Thread.current[:coolhand_capture_override] = previous
|
|
83
|
+
def without_capture(&block)
|
|
84
|
+
override_capture(false, &block)
|
|
83
85
|
end
|
|
84
86
|
|
|
85
|
-
def with_capture
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
87
|
+
def with_capture(&block)
|
|
88
|
+
override_capture(true, &block)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Declares the workload/template that LLM calls captured inside the block belong to. Nested
|
|
92
|
+
# blocks override per field (`reporter`, `workload`, `template`), innermost wins. `workload` and
|
|
93
|
+
# `template` are Hashes with `id:` (an existing record) or `key:` (find-or-created), plus optional
|
|
94
|
+
# `name:` (and `description:` on a workload). See docs/declared_workloads.md.
|
|
95
|
+
#
|
|
96
|
+
# Example:
|
|
97
|
+
# Coolhand.with_context(reporter: "squishling", workload: { key: "Parser#call" }) { client.chat(...) }
|
|
98
|
+
def with_context(reporter: nil, workload: nil, template: nil, &block)
|
|
99
|
+
raise ArgumentError, "Coolhand.with_context requires a block" unless block
|
|
100
|
+
|
|
101
|
+
DeclaredContext.with(reporter: reporter, workload: workload, template: template, &block)
|
|
91
102
|
end
|
|
92
103
|
|
|
93
104
|
# Blocks until log requests queued by async logging have been sent, or the timeout (in
|
|
@@ -149,5 +160,16 @@ module Coolhand
|
|
|
149
160
|
|
|
150
161
|
true
|
|
151
162
|
end
|
|
163
|
+
|
|
164
|
+
private
|
|
165
|
+
|
|
166
|
+
# Forces capture on or off for the block on this thread, restoring the previous override after.
|
|
167
|
+
def override_capture(enabled)
|
|
168
|
+
previous = Thread.current[:coolhand_capture_override]
|
|
169
|
+
Thread.current[:coolhand_capture_override] = enabled
|
|
170
|
+
yield
|
|
171
|
+
ensure
|
|
172
|
+
Thread.current[:coolhand_capture_override] = previous
|
|
173
|
+
end
|
|
152
174
|
end
|
|
153
175
|
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: coolhand
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.9.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-10-
|
|
12
|
+
date: 2026-10-18 00:00:00.000000000 Z
|
|
13
13
|
dependencies:
|
|
14
14
|
- !ruby/object:Gem::Dependency
|
|
15
15
|
name: base64
|
|
@@ -47,6 +47,7 @@ files:
|
|
|
47
47
|
- SECURITY.md
|
|
48
48
|
- docs/anthropic.md
|
|
49
49
|
- docs/configuration.md
|
|
50
|
+
- docs/declared_workloads.md
|
|
50
51
|
- docs/elevenlabs.md
|
|
51
52
|
- docs/feedback-links.md
|
|
52
53
|
- docs/feedback.md
|
|
@@ -60,6 +61,8 @@ files:
|
|
|
60
61
|
- lib/coolhand/base_interceptor.rb
|
|
61
62
|
- lib/coolhand/collector.rb
|
|
62
63
|
- lib/coolhand/configuration.rb
|
|
64
|
+
- lib/coolhand/declarable.rb
|
|
65
|
+
- lib/coolhand/declared_context.rb
|
|
63
66
|
- lib/coolhand/default_exclude_api_patterns.yml
|
|
64
67
|
- lib/coolhand/default_intercept_addresses.yml
|
|
65
68
|
- lib/coolhand/default_intercept_path_patterns.yml
|
|
@@ -74,8 +77,11 @@ files:
|
|
|
74
77
|
- lib/coolhand/open_ai/webhook_validator.rb
|
|
75
78
|
- lib/coolhand/optimization_feedback_link_service.rb
|
|
76
79
|
- lib/coolhand/pagination.rb
|
|
80
|
+
- lib/coolhand/rails_auto_declare.rb
|
|
81
|
+
- lib/coolhand/railtie.rb
|
|
77
82
|
- lib/coolhand/read_query.rb
|
|
78
83
|
- lib/coolhand/read_requests.rb
|
|
84
|
+
- lib/coolhand/response_warnings.rb
|
|
79
85
|
- lib/coolhand/template_service.rb
|
|
80
86
|
- lib/coolhand/version.rb
|
|
81
87
|
- lib/coolhand/vertex/batch_result_processor.rb
|