coolhand 0.6.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,116 @@
1
+ # Reading Workloads (Search)
2
+
3
+ `Coolhand::WorkloadService#search_workloads` lists your workloads, the groups of prompt templates that
4
+ make up one task or agent, optionally with a cost and performance `metrics` rollup per workload. It
5
+ wraps `GET /api/v2/workloads`. It is read-only, and there is no per-workload endpoint.
6
+
7
+ **Auth:** requires your **private** API key. The public key is write-only on this API and is rejected
8
+ exactly like an invalid key.
9
+
10
+ **IDs:** a workload's `:id` is its **hashid**, a String. It is what the `workload_id` filter on
11
+ `search_templates` and `search_logs` expects.
12
+
13
+ **Scoping:** the client is always derived from your API key. There is deliberately no `client_id`
14
+ keyword, so passing one raises `ArgumentError`.
15
+
16
+ ## `search_workloads`
17
+
18
+ ```ruby
19
+ require "coolhand"
20
+
21
+ Coolhand.configure do |config|
22
+ config.api_key = ENV.fetch("COOLHAND_PRIVATE_API_KEY")
23
+ end
24
+
25
+ result = Coolhand.workload_service.search_workloads(
26
+ include_metrics: true,
27
+ since: Time.utc(2026, 9, 1),
28
+ until: Time.utc(2026, 10, 1),
29
+ per: 50
30
+ )
31
+
32
+ result.workloads.each do |workload|
33
+ puts "#{workload[:name]}: $#{workload[:metrics][:total_cost]}"
34
+ end
35
+ ```
36
+
37
+ ### Keywords
38
+
39
+ All optional, and named exactly like the wire parameters.
40
+
41
+ | keyword | type | notes |
42
+ |---|---|---|
43
+ | `search` | String | Case-insensitive substring match on the workload name. |
44
+ | `include_archived` | Boolean | Defaults to `false` server-side. |
45
+ | `include_system` | Boolean | `Unmatched`, `Embedding Requests` and the like. Defaults to `false` server-side. |
46
+ | `include_templates` | Boolean | Adds each workload's active templates and their routing patterns as `:templates`. |
47
+ | `include_metrics` | Boolean | Adds a `:metrics` rollup across all of the workload's templates. |
48
+ | `days_back` | Integer | Rolling metrics window ending now (server default 28, max 365). Ignored when `since` is given. |
49
+ | `since` | Time, DateTime, Date or String | Metrics window start, inclusive. Overrides `days_back`. |
50
+ | `until` | Time, DateTime, Date or String | Metrics window end, exclusive. Defaults to now. |
51
+ | `page` | Integer | 1-based. |
52
+ | `per` | Integer | Default 25, max 100, both enforced server-side. This gem only sends `per`, never its `per_page` alias. |
53
+
54
+ `false` and `0` are sent as given; only an unset (`nil`) keyword is left off the request.
55
+
56
+ ### Windows: `since` and `until`
57
+
58
+ `Time`, `DateTime` and `Date` values are serialised as UTC ISO8601 (a `Date` as a date alone, which the
59
+ server reads as midnight UTC). A String is passed through untouched, so the server stays the one
60
+ validator. Anything else raises `ArgumentError` before a request is made.
61
+
62
+ ```ruby
63
+ search_workloads(include_metrics: true, since: "2026-09-01") # date alone: midnight UTC
64
+ search_workloads(include_metrics: true, since: "2026-09-01T12:00:00+02:00") # the + is encoded as %2B for you
65
+ ```
66
+
67
+ Server-side rules, which only apply when `include_metrics` is set:
68
+
69
+ - A missing offset means UTC. `since` is inclusive and `until` is exclusive.
70
+ - With `since`, `days_back` is ignored and `metrics[:days_back]` is `nil`. With only `until`, the window
71
+ is `days_back` long ending there.
72
+ - The prior window behind `error_rate_change` is the same length, immediately before the window.
73
+ - A malformed value, a `since` not before `until`, or a window over 365 days is a `422` with the error on
74
+ the `since` or `until` key.
75
+
76
+ ### Return value
77
+
78
+ A `Coolhand::WorkloadSearchResult`, a `Struct` with `#workloads` and `#pagination`. Rows are plain
79
+ Hashes with Symbol keys, handed back as the API rendered them so a field the server adds later reaches
80
+ you.
81
+
82
+ | field | type | notes |
83
+ |---|---|---|
84
+ | `:id` | String | Hashid. |
85
+ | `:name` | String | |
86
+ | `:description` | String or nil | |
87
+ | `:archived`, `:system`, `:merged` | Boolean | |
88
+ | `:template_count` | Integer | Active templates. |
89
+ | `:draft_template_count` | Integer | |
90
+ | `:log_count` | Integer | Every log on any of its templates, all generators, so it can exceed the sum of the templates' own `log_count`. |
91
+ | `:last_activity` | String or nil | ISO-8601 UTC. |
92
+ | `:templates` | Array | Only with `include_templates`. Each has `:id`, `:name`, `:status`, `:user_prompt_pattern`, `:system_prompt_pattern`. |
93
+ | `:metrics` | Hash | Only with `include_metrics`. See below. |
94
+
95
+ Rows are ordered by name. `pagination` is a `Coolhand::Pagination` built from the `X-Page`,
96
+ `X-Per-Page`, `X-Total-Count` and `X-Total-Pages` response headers, which this endpoint always sends,
97
+ never from the length of the array.
98
+
99
+ ### `metrics`
100
+
101
+ The same object `search_templates` returns per template, computed by the same SQL as the dashboard, so
102
+ tiered pricing, cached-token discounts and reasoning tokens are applied. See
103
+ [Metrics](template-search.md#metrics) for every field.
104
+
105
+ ## Errors
106
+
107
+ The read methods raise where the write methods log and return `nil`. `Coolhand::HttpError` carries
108
+ `#status` and `#body` (`{"errors": {"<field>": ["msg"]}}` on a `422`).
109
+
110
+ | status | when |
111
+ |---|---|
112
+ | `401` | No API key, an invalid key, or the public key. |
113
+ | `422` | A bad `days_back`, `since` or `until`. Only checked when `include_metrics` is set. |
114
+ | `504` | An aggregate exceeded the server's statement timeout. Retryable: narrow with `search` or a smaller `per`. |
115
+
116
+ `Coolhand::Error` covers no API key configured, a transport failure, and a body that is not a JSON array.
@@ -80,9 +80,10 @@ module Coolhand
80
80
  uri = URI.parse(@api_endpoint)
81
81
  http = Net::HTTP.new(uri.host, uri.port)
82
82
  http.use_ssl = (uri.scheme == "https")
83
- # Bound worst-case latency: this call happens inline in the intercepted
84
- # request's path, so a slow/unreachable Coolhand backend must not hang
85
- # the host app's real LLM call for Ruby's ~60s Net::HTTP defaults.
83
+ # Bound worst-case latency: with async_logging off this call happens inline in
84
+ # the intercepted request's path (otherwise it ties up the background log
85
+ # worker), so a slow/unreachable Coolhand backend must not hang for Ruby's
86
+ # ~60s Net::HTTP defaults.
86
87
  http.open_timeout = 5
87
88
  http.read_timeout = 5
88
89
 
@@ -11,7 +11,7 @@ module Coolhand
11
11
  # (cookie, set-cookie), and future/unknown providers using a
12
12
  # similarly-named header. Shared with LoggerService so the two logging
13
13
  # paths (interceptor + webhook forwarding) stay consistent.
14
- SENSITIVE_HEADER_PATTERN = /key|token|secret|signature|authorization|cookie/i
14
+ SENSITIVE_HEADER_PATTERN = /key|token|secret|signature|auth|passw|credential|bearer|jwt|session|cookie/i
15
15
 
16
16
  def sanitize_headers(headers)
17
17
  return {} if headers.nil?
@@ -110,7 +110,42 @@ module Coolhand
110
110
 
111
111
  modified ? uri.to_s : url
112
112
  rescue URI::InvalidURIError
113
- url
113
+ # Fail closed: an unparseable URL can't be checked param-by-param, so drop everything
114
+ # that could carry a credential (userinfo, query, fragment) rather than pass it through.
115
+ url.to_s.sub(%r{//[^/?#]*@}, "//REDACTED@").split(/[?#]/, 2).first
116
+ end
117
+
118
+ # Key names inside an Azure OpenAI "On Your Data" data_sources/dataSources entry that carry
119
+ # live datastore credentials in the request body, where header/query-param sanitization never
120
+ # looks. Matched against a separator-stripped, lowercased key name so both `connection_string`
121
+ # and `connectionString` (Cosmos/Mongo, embeds `AccountKey=...`) and `encoded_api_key`
122
+ # (Elasticsearch) are caught — not just exact or snake_case-only names.
123
+ SENSITIVE_BODY_KEY_PATTERN = /key|token|secret|passw|pwd|credential|bearer|jwt|connectionstring|signature/i
124
+
125
+ def sanitize_body(body)
126
+ return body unless body.is_a?(Hash)
127
+
128
+ sanitized = body.dup
129
+ sanitized.each_key do |key|
130
+ next unless key.to_s.delete("_-").casecmp?("datasources")
131
+
132
+ sanitized[key] = redact_sensitive_body_values(sanitized[key])
133
+ end
134
+ sanitized
135
+ end
136
+
137
+ def redact_sensitive_body_values(node)
138
+ case node
139
+ when Hash
140
+ node.each_with_object({}) do |(k, v), acc|
141
+ normalized = k.to_s.gsub(/[_\-\s]/, "").downcase
142
+ acc[k] = normalized.match?(SENSITIVE_BODY_KEY_PATTERN) ? "[REDACTED]" : redact_sensitive_body_values(v)
143
+ end
144
+ when Array
145
+ node.map { |v| redact_sensitive_body_values(v) }
146
+ else
147
+ node
148
+ end
114
149
  end
115
150
 
116
151
  def send_complete_request_log(request_id:, method:, url:, request_headers:, request_body:, response_headers:,
@@ -121,7 +156,7 @@ module Coolhand
121
156
  method: method.to_s.downcase,
122
157
  url: sanitize_url(url),
123
158
  headers: sanitize_headers(request_headers),
124
- request_body: request_body,
159
+ request_body: sanitize_body(request_body),
125
160
  response_headers: sanitize_headers(response_headers),
126
161
  response_body: response_body,
127
162
  status_code: status_code,
@@ -134,10 +169,10 @@ module Coolhand
134
169
 
135
170
  request_data = { raw_request: raw_request }
136
171
 
137
- api_service = Coolhand::ApiService.new
138
- api_service.send_llm_request_log(request_data)
139
-
140
- Coolhand.log "📤 Sent complete request/response log for #{request_id} (duration: #{duration_ms}ms)"
172
+ LogQueue.submit do
173
+ Coolhand::ApiService.new.send_llm_request_log(request_data)
174
+ Coolhand.log "📤 Sent complete request/response log for #{request_id} (duration: #{duration_ms}ms)"
175
+ end
141
176
  rescue StandardError => e
142
177
  Coolhand.log "❌ Error sending complete request log: #{e.message}"
143
178
  end
@@ -29,7 +29,7 @@ module Coolhand
29
29
  DEFAULT_MAX_CAPTURED_BODY_BYTES = 1_000_000
30
30
 
31
31
  attr_accessor :api_key, :environment, :silent, :debug_mode, :capture, :exclude_api_patterns, :enabled,
32
- :max_captured_body_bytes, :webhook_replay_tolerance_seconds, :webhook_id_store
32
+ :max_captured_body_bytes, :webhook_replay_tolerance_seconds, :webhook_id_store, :async_logging
33
33
  attr_reader :intercept_addresses, :intercept_path_patterns, :base_url
34
34
 
35
35
  def initialize
@@ -44,6 +44,7 @@ module Coolhand
44
44
  @capture = true
45
45
  @exclude_api_patterns = DEFAULT_EXCLUDE_API_PATTERNS.dup
46
46
  @enabled = true
47
+ @async_logging = true
47
48
  @max_captured_body_bytes = DEFAULT_MAX_CAPTURED_BODY_BYTES
48
49
  @webhook_replay_tolerance_seconds = 300
49
50
  @webhook_id_store = Coolhand::OpenAi::WebhookIdStore.new
@@ -8,3 +8,14 @@
8
8
  # Users can disable: c.exclude_api_patterns = []
9
9
 
10
10
  - "/batchPredictionJobs/"
11
+ # Azure OpenAI control-plane paths (not inference) — both spellings are needed
12
+ # since substring matching is contiguous: "/openai/files" does not match
13
+ # "/openai/v1/files".
14
+ - "/openai/files"
15
+ - "/openai/batches"
16
+ - "/openai/fine_tuning"
17
+ - "/openai/models"
18
+ - "/openai/v1/files"
19
+ - "/openai/v1/batches"
20
+ - "/openai/v1/fine_tuning"
21
+ - "/openai/v1/models"
@@ -4,6 +4,10 @@
4
4
  # should be captured and forwarded as an llm_request_log. A single "*" in an
5
5
  # entry matches exactly one host label — e.g. "bedrock-runtime.*.amazonaws.com"
6
6
  # matches "bedrock-runtime.us-east-1.amazonaws.com" but nothing else.
7
+ # An entry may also pin a port and/or a path prefix: "host:port", "host/path",
8
+ # or "host:port/path" (the path match is on a segment boundary) — useful for
9
+ # multi-service hosts where unrelated APIs (e.g. Azure Speech, Vision, Language)
10
+ # share a domain with the LLM inference path.
7
11
  #
8
12
  # Users can extend defaults: c.intercept_addresses << "my.custom.api.com"
9
13
  # Users can override entirely: c.intercept_addresses = ["only.this.com"]
@@ -20,5 +24,55 @@
20
24
  - "aiplatform.googleapis.com"
21
25
  - "gateway.ai.cloudflare.com"
22
26
  - "bedrock-runtime.*.amazonaws.com"
27
+ - "api.deepseek.com"
28
+ - "api.mistral.ai"
29
+ - "api.perplexity.ai"
30
+ - "api.x.ai"
31
+ # Cohere is path-scoped, never host-wide: the server only ingests v2 chat and
32
+ # v1/v2 embed, and its other endpoints (v1 chat, rerank, tokenize, classify...)
33
+ # have different envelopes that would be recorded as empty "successes".
34
+ - "api.cohere.com/v2/chat"
35
+ - "api.cohere.com/v1/embed"
36
+ - "api.cohere.com/v2/embed"
37
+ - "api.cohere.ai/v2/chat"
38
+ - "api.cohere.ai/v1/embed"
39
+ - "api.cohere.ai/v2/embed"
40
+ # TypeSafe Jev (a "System One" model, not an LLM) — only /v1/systemone is ingested.
41
+ - "api.typesafe.ai/v1/systemone"
42
+ # Ollama (self-hosted) has no fixed host, so an entry must pin BOTH the default
43
+ # port (11434) and the path — a bare "/api/chat" would capture any app's own
44
+ # unrelated chat endpoint. "*" matches one host label (localhost, an "ollama"
45
+ # compose service, a bare hostname); a LAN IP or dotted host needs its own
46
+ # entry, e.g. "192.168.1.5:11434/api/chat".
47
+ - "*:11434/api/chat"
48
+ - "*:11434/api/generate"
49
+ - "*:11434/api/embed"
50
+ - "*:11434/api/embeddings"
51
+ - "127.0.0.1:11434/api/chat"
52
+ - "127.0.0.1:11434/api/generate"
53
+ - "127.0.0.1:11434/api/embed"
54
+ - "127.0.0.1:11434/api/embeddings"
23
55
  - "openrouter.ai"
24
56
  - "opencode.ai"
57
+ # Azure OpenAI dedicated hosts (commercial, US Gov, China/21Vianet)
58
+ - "openai.azure.com"
59
+ - "openai.azure.us"
60
+ - "openai.azure.cn"
61
+ # Azure AI Services / Foundry multi-service hosts — path-anchored so Speech,
62
+ # Vision, Language and Content Safety on the same host are NOT captured
63
+ - "cognitiveservices.azure.com/openai/"
64
+ - "cognitiveservices.azure.com/models/"
65
+ - "cognitiveservices.azure.us/openai/"
66
+ - "cognitiveservices.azure.us/models/"
67
+ - "cognitiveservices.azure.cn/openai/"
68
+ - "cognitiveservices.azure.cn/models/"
69
+ - "services.ai.azure.com/openai/"
70
+ - "services.ai.azure.com/models/"
71
+ - "services.ai.azure.us/openai/"
72
+ - "services.ai.azure.us/models/"
73
+ # Serverless / MaaS + Azure ML managed online endpoints. inference.ml.azure.*
74
+ # is deliberately unanchored — see docs/configuration.md for why.
75
+ - "inference.ai.azure.com"
76
+ - "models.ai.azure.com"
77
+ - "inference.ml.azure.com"
78
+ - "inference.ml.azure.us"
@@ -3,7 +3,8 @@
3
3
  module Coolhand
4
4
  class Error < StandardError; end
5
5
 
6
- # Raised by the gem's read methods when the Coolhand API answers with a non-2xx status.
6
+ # Raised by the gem's raising API methods (template reads, feedback links) when the Coolhand API
7
+ # answers with a non-2xx status.
7
8
  #
8
9
  # `status` is carried so callers can branch on it (404 vs retryable 504) without matching the
9
10
  # message text.
@@ -0,0 +1,179 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Coolhand
4
+ # Runs log-shipping work on background threads so a slow or unreachable Coolhand backend can't
5
+ # add latency to the host app's own LLM calls.
6
+ #
7
+ # Work is spread over two lanes, each with its own bounded queue and worker thread: `:default`
8
+ # for the one-log-per-intercepted-call sends, and `:batch` for whole-batch jobs (download +
9
+ # one send per item) that can run for minutes. Keeping them apart stops a long batch from
10
+ # delaying — or, once the queue fills, evicting — the real-time logs.
11
+ #
12
+ # Each queue is bounded (oldest job dropped when full), restarts itself in a forked child, and
13
+ # is flushed at process exit. Work runs inline instead when `config.async_logging` is false,
14
+ # when `config.debug_mode` is on (debug output should appear in order), or when the caller is
15
+ # already a worker thread (a queued job's own sends don't re-enter a queue).
16
+ module LogQueue
17
+ MAX_QUEUE_SIZE = 1000
18
+ DEFAULT_FLUSH_TIMEOUT = 5
19
+
20
+ # One bounded FIFO plus the thread that drains it.
21
+ class Lane
22
+ attr_reader :thread
23
+
24
+ def initialize
25
+ @mutex = Mutex.new
26
+ @wakeup = ConditionVariable.new
27
+ @idle = ConditionVariable.new
28
+ @jobs = []
29
+ @thread = nil
30
+ @pid = Process.pid
31
+ @busy = false
32
+ end
33
+
34
+ def enqueue(job)
35
+ @mutex.synchronize do
36
+ restart_after_fork
37
+ drop_oldest if @jobs.size >= MAX_QUEUE_SIZE
38
+ @jobs << job
39
+ ensure_worker
40
+ @wakeup.signal
41
+ end
42
+ end
43
+
44
+ # True once every queued job has finished, false if the timeout elapsed first.
45
+ def flush(deadline)
46
+ @mutex.synchronize do
47
+ # A forked child's copy of the queue belongs to the parent's worker; nothing to wait on.
48
+ return true if @pid != Process.pid
49
+ # A dead worker will never drain the queue, so don't wait on it.
50
+ return @jobs.empty? unless @thread&.alive?
51
+
52
+ while @jobs.any? || @busy
53
+ remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
54
+ return false unless remaining.positive?
55
+
56
+ @idle.wait(@mutex, remaining)
57
+ end
58
+ true
59
+ end
60
+ end
61
+
62
+ def reset!
63
+ thread = nil
64
+ @mutex.synchronize do
65
+ thread = @thread
66
+ @jobs.clear
67
+ @thread = nil
68
+ @busy = false
69
+ @wakeup.broadcast
70
+ @idle.broadcast
71
+ end
72
+ thread&.join(1)
73
+ thread&.kill if thread&.alive?
74
+ end
75
+
76
+ private
77
+
78
+ def drop_oldest
79
+ @jobs.shift
80
+ Coolhand.log "⚠️ Log queue full (#{MAX_QUEUE_SIZE}); dropping the oldest pending log"
81
+ end
82
+
83
+ # A forked child inherits the queue's memory but not its worker thread.
84
+ def restart_after_fork
85
+ return if @pid == Process.pid
86
+
87
+ @pid = Process.pid
88
+ @jobs = []
89
+ @thread = nil
90
+ @busy = false
91
+ end
92
+
93
+ def ensure_worker
94
+ return if @thread&.alive?
95
+
96
+ @thread = Thread.new { work }
97
+ @thread.report_on_exception = false
98
+ end
99
+
100
+ def work
101
+ loop do
102
+ job = next_job
103
+ break unless job
104
+
105
+ begin
106
+ run(job)
107
+ ensure
108
+ @mutex.synchronize do
109
+ @busy = false
110
+ @idle.broadcast
111
+ end
112
+ end
113
+ end
114
+ end
115
+
116
+ def next_job
117
+ @mutex.synchronize do
118
+ @wakeup.wait(@mutex) while @jobs.empty? && @thread == Thread.current
119
+ return nil unless @thread == Thread.current
120
+
121
+ @busy = true
122
+ @jobs.shift
123
+ end
124
+ end
125
+
126
+ def run(job)
127
+ job.call
128
+ rescue StandardError => e
129
+ Coolhand.log "❌ Error in background log job: #{e.message}"
130
+ end
131
+ end
132
+
133
+ LANES = { default: Lane.new, batch: Lane.new }.freeze
134
+
135
+ @at_exit_mutex = Mutex.new
136
+ @at_exit_registered = false
137
+
138
+ class << self
139
+ def submit(lane: :default, &job)
140
+ return yield unless async?
141
+
142
+ register_at_exit
143
+ LANES.fetch(lane).enqueue(job)
144
+ nil
145
+ end
146
+
147
+ # Blocks until every queued job has finished or the timeout elapses. Returns true when
148
+ # every lane drained in time.
149
+ def flush(timeout = DEFAULT_FLUSH_TIMEOUT)
150
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
151
+ LANES.values.map { |lane| lane.flush(deadline) }.all?
152
+ end
153
+
154
+ # Testing-only: discard queued work and stop the workers.
155
+ def reset!
156
+ LANES.each_value(&:reset!)
157
+ end
158
+
159
+ private
160
+
161
+ def async?
162
+ Coolhand.configuration.async_logging && !Coolhand.configuration.debug_mode && !worker_thread?
163
+ end
164
+
165
+ def worker_thread?
166
+ LANES.each_value.any? { |lane| lane.thread == Thread.current }
167
+ end
168
+
169
+ def register_at_exit
170
+ @at_exit_mutex.synchronize do
171
+ return if @at_exit_registered
172
+
173
+ @at_exit_registered = true
174
+ at_exit { flush }
175
+ end
176
+ end
177
+ end
178
+ end
179
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "api_service"
4
+ require_relative "pagination"
5
+ require_relative "read_query"
6
+
7
+ module Coolhand
8
+ # Rows stay plain Symbol-keyed Hashes so a field the server adds later is not dropped in transit.
9
+ LogSearchResult = Struct.new(:logs, :pagination, keyword_init: true)
10
+
11
+ # Reads logs back out of Coolhand. The write side is {LoggerService}. Requires the client's
12
+ # **private** key. See docs/log-search.md.
13
+ class LogService < ApiService
14
+ include ReadQuery
15
+
16
+ ERROR_NOUN = "Log"
17
+ BLANK_ID_MESSAGE = "get_log: id must be a non-empty log hashid"
18
+
19
+ def initialize
20
+ super("v2/llm_request_logs")
21
+ end
22
+
23
+ # Filters, cost ordering, error semantics and server behaviour: docs/log-search.md.
24
+ def search_logs(template_id: nil, workload_id: nil, system_prompt_contains: nil, user_prompt_contains: nil,
25
+ model: nil, source_api: nil, source_api_result: nil, source_application: nil, project_path: nil,
26
+ unmatched_only: nil, days_back: nil, since: nil, until: nil, min_cost: nil, order: nil,
27
+ include_prompts: nil, include_total: nil, sort: nil, page: nil, per: nil)
28
+ query = {
29
+ template_id: template_id,
30
+ workload_id: workload_id,
31
+ system_prompt_contains: system_prompt_contains,
32
+ user_prompt_contains: user_prompt_contains,
33
+ model: model,
34
+ source_api: source_api,
35
+ source_api_result: source_api_result,
36
+ source_application: source_application,
37
+ project_path: project_path,
38
+ unmatched_only: unmatched_only,
39
+ days_back: days_back,
40
+ **window_params(since, binding.local_variable_get(:until)),
41
+ min_cost: min_cost,
42
+ order: order,
43
+ include_prompts: include_prompts,
44
+ include_total: include_total,
45
+ "q[s]": sort,
46
+ page: page,
47
+ per: per
48
+ }.compact
49
+
50
+ logs, response = get_json_with_headers(list_url(query), ERROR_NOUN)
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
57
+ end
58
+
59
+ # The full log, with `cost` and `cost_breakdown`. Both are nil when the log has no tokens or its
60
+ # model has no pricing.
61
+ def get_log(id, section: nil, max_chars: nil, search_query: nil, include_thinking: nil)
62
+ query = { section: section, max_chars: max_chars, search_query: search_query,
63
+ include_thinking: include_thinking }.compact
64
+ get_json(resource_url(id, BLANK_ID_MESSAGE, query), ERROR_NOUN)
65
+ end
66
+ end
67
+ end