ruby_llm-ups 0.1.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 78938b9d6f46ff771624b50d0f0932f70670aeb2c1fba8114cb706fb20cebabf
4
+ data.tar.gz: 6cb294cb6d6b6abdf3a794615b86f9322cd3131beb34a27098d0109d96e6b017
5
+ SHA512:
6
+ metadata.gz: 79207f86600028793f3c2f881a25e5e1d4adf4dffdd5537c085fab512a99465d729f5e44768a933a8d5e7ae5b4b824a47b365197cb5bcd830dc28c6c7df9fcb7
7
+ data.tar.gz: 320e462bef0b2f46f04e77b590aa6955f2b6ab5e712dfc0876dc6d118b5ff44228de06f2100e1e094e414c0005d0061b2c2f96cfabae7b1c5b73b455ffd14138
data/CHANGELOG.md ADDED
@@ -0,0 +1,16 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ ## [0.1.0] - 2026-03-16
6
+
7
+ ### Added
8
+
9
+ - Automatic heartbeat reporting after each successful LLM response (model, provider, response time, tool count)
10
+ - `Monitored` mixin for automatic per-agent component creation on ups.dev
11
+ - `ComponentRegistry` for find-or-create component semantics
12
+ - Manual status reporting via `report_status` (operational, degraded, partial/major outage, maintenance)
13
+ - Incident lifecycle management: create, update, resolve
14
+ - Async background reporter with debouncing and circuit breaker
15
+ - Rails integration via Railtie with auto-configuration from credentials
16
+ - Configurable error handling — monitoring failures never interrupt LLM workflows
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2025 Valentino Stoll
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,244 @@
1
+ # RubyLLM::Ups
2
+
3
+ Connect your RubyLLM-powered agents to [ups.dev](https://ups.dev) status pages.
4
+
5
+ After each LLM response, the gem sends a lightweight heartbeat to ups.dev — model, provider, response time, tool count. **No message content is ever sent.** Status degradation and incidents are reported by you, when you decide they matter.
6
+
7
+ ## Installation
8
+
9
+ ```ruby
10
+ gem "ruby_llm-ups"
11
+ ```
12
+
13
+ ## Quick Start
14
+
15
+ ### 1. Configure
16
+
17
+ ```ruby
18
+ RubyLLM::Ups.configure do |c|
19
+ c.api_key = "ups_live_abc123_xyz789"
20
+ c.status_page_id = "my-page-slug" # your status page slug or numeric ID
21
+ end
22
+ ```
23
+
24
+ In Rails, credentials are auto-loaded — no initializer needed if you set:
25
+
26
+ ```yaml
27
+ # config/credentials.yml.enc
28
+ ups:
29
+ api_key: ups_live_abc123_xyz789
30
+ status_page_id: my-page-slug
31
+ ```
32
+
33
+ ### 2. Include `Monitored` in your base agent
34
+
35
+ ```ruby
36
+ class ApplicationAgent < RubyLLM::Agent
37
+ include RubyLLM::Ups::Monitored
38
+ end
39
+ ```
40
+
41
+ That's it. Every agent that inherits from `ApplicationAgent` gets its own component on ups.dev, auto-created by name. `ResearchAgent` becomes "Research Agent", `DataProcessor` becomes "Data Processor Agent".
42
+
43
+ ```ruby
44
+ class ResearchAgent < ApplicationAgent
45
+ model "claude-sonnet-4-20250514"
46
+ tools SearchTool, SummarizeTool
47
+ end
48
+
49
+ agent = ResearchAgent.new # → "Research Agent" component created on ups.dev
50
+ agent.ask("Hello") # → heartbeat: operational + metadata
51
+ ```
52
+
53
+ Override the default name with `ups_component`:
54
+
55
+ ```ruby
56
+ class MyAgent < ApplicationAgent
57
+ ups_component "Customer Support Bot"
58
+ end
59
+ ```
60
+
61
+ ## What gets reported
62
+
63
+ Every successful LLM response sends:
64
+
65
+ ```ruby
66
+ {
67
+ model: "claude-sonnet-4-20250514", # which model handled the request
68
+ provider: "anthropic", # the LLM provider
69
+ last_response_time_ms: 1230, # end-to-end response time
70
+ tool_count: 3 # number of tools available
71
+ }
72
+ ```
73
+
74
+ Failed LLM calls report nothing — the monitor only fires on successful responses. You decide when a failure is worth reporting (see below).
75
+
76
+ ## Reporting failures
77
+
78
+ ### Status degradation
79
+
80
+ Use `report_status` when you detect a problem — partial failures, elevated error rates, slow responses:
81
+
82
+ ```ruby
83
+ RubyLLM::Ups.report_status(:degraded_performance,
84
+ component_id: component_id,
85
+ agent_metadata: {
86
+ failed_step: "summarization",
87
+ error: "Provider timeout"
88
+ }
89
+ )
90
+ ```
91
+
92
+ ### Incidents
93
+
94
+ Create incidents for failures your users should know about:
95
+
96
+ ```ruby
97
+ RubyLLM::Ups.create_incident(
98
+ title: "Processing pipeline down",
99
+ impact: :major,
100
+ status: :investigating,
101
+ description: "Failed at enrichment stage: API rate limit exceeded"
102
+ )
103
+ ```
104
+
105
+ ### Pipeline pattern
106
+
107
+ A common pattern for multi-stage agent pipelines:
108
+
109
+ ```ruby
110
+ class Pipeline
111
+ def call
112
+ stages.each { |stage| run_stage(stage) }
113
+ ups_report_operational
114
+ rescue => e
115
+ ups_create_incident(e)
116
+ raise
117
+ end
118
+
119
+ def run_stage(stage)
120
+ stage.call
121
+ rescue => e
122
+ if optional_stage?(stage)
123
+ ups_report_degraded(stage, e) # partial failure — degraded, not down
124
+ else
125
+ raise # critical failure — will create incident
126
+ end
127
+ end
128
+
129
+ private
130
+
131
+ def pipeline_component_id
132
+ @pipeline_component_id ||= begin
133
+ component = RubyLLM::Ups.component_registry.find_or_create("My Pipeline")
134
+ component["id"]
135
+ end
136
+ end
137
+
138
+ def ups_report_operational
139
+ RubyLLM::Ups.report_status(:operational, component_id: pipeline_component_id)
140
+ rescue => e
141
+ Rails.logger.warn("[ups.dev] #{e.message}")
142
+ end
143
+
144
+ def ups_report_degraded(stage, error)
145
+ RubyLLM::Ups.report_status(:degraded_performance,
146
+ component_id: pipeline_component_id,
147
+ agent_metadata: { failed_stage: stage.name, error: error.message }
148
+ )
149
+ rescue => e
150
+ Rails.logger.warn("[ups.dev] #{e.message}")
151
+ end
152
+
153
+ def ups_create_incident(error)
154
+ RubyLLM::Ups.create_incident(
155
+ title: "Pipeline failed",
156
+ impact: :major,
157
+ status: :investigating,
158
+ description: error.message
159
+ )
160
+ rescue => e
161
+ Rails.logger.warn("[ups.dev] #{e.message}")
162
+ end
163
+ end
164
+ ```
165
+
166
+ Key points:
167
+ - Use `component_registry.find_or_create` to get a named component for non-agent things
168
+ - Always rescue ups.dev calls — monitoring failures should never break your app
169
+ - `report_status(:operational)` after success resets a previously degraded component
170
+
171
+ ## Incident lifecycle
172
+
173
+ ```ruby
174
+ # Open
175
+ incident = RubyLLM::Ups.create_incident(
176
+ title: "Agent responding slowly",
177
+ impact: :minor, # :none, :minor, :major, :critical
178
+ status: :investigating # :investigating, :identified, :monitoring, :resolved
179
+ )
180
+
181
+ # Update
182
+ RubyLLM::Ups.update_incident(incident["incident"]["id"],
183
+ status: :identified,
184
+ update_message: "Root cause: Anthropic API degradation"
185
+ )
186
+
187
+ # Resolve
188
+ RubyLLM::Ups.resolve_incident(incident["incident"]["id"])
189
+ ```
190
+
191
+ ## Configuration
192
+
193
+ | Option | Default | Description |
194
+ |---|---|---|
195
+ | `api_key` | required | Your ups.dev API key |
196
+ | `status_page_id` | required | Status page slug or numeric ID |
197
+ | `component_id` | — | Default component for `monitor` and `report_status` (optional with `Monitored`) |
198
+ | `base_url` | `https://ups.dev` | API base URL |
199
+ | `request_timeout` | `30` | HTTP timeout in seconds |
200
+ | `on_error` | stderr | Error handler proc |
201
+ | `async` | `true` | Send heartbeats in a background thread |
202
+ | `flush_interval` | `5` | Seconds between async flushes |
203
+ | `circuit_breaker_threshold` | `5` | Consecutive failures before opening circuit |
204
+ | `circuit_breaker_timeout` | `60` | Seconds to keep circuit open before retrying |
205
+
206
+ ### Error handling
207
+
208
+ Monitoring errors never interrupt your LLM workflows. Route them somewhere useful:
209
+
210
+ ```ruby
211
+ RubyLLM::Ups.configure do |c|
212
+ c.on_error = ->(e) { Rails.logger.warn("[ups.dev] #{e.message}") }
213
+ end
214
+ ```
215
+
216
+ ## Manual monitoring
217
+
218
+ If you don't want `Monitored`, call `monitor()` directly:
219
+
220
+ ```ruby
221
+ RubyLLM::Ups.configure do |c|
222
+ c.api_key = "..."
223
+ c.status_page_id = "..."
224
+ c.component_id = "my-agent-id"
225
+ end
226
+
227
+ chat = RubyLLM.chat(model: "claude-sonnet-4-20250514")
228
+ RubyLLM::Ups.monitor(chat)
229
+ chat.ask("Hello") # → heartbeat sent
230
+ ```
231
+
232
+ ## Direct client access
233
+
234
+ ```ruby
235
+ client = RubyLLM::Ups.client
236
+
237
+ client.list_components
238
+ client.create_component(name: "New Agent", component_type: "agent")
239
+ client.update_component("comp-id", status: :operational, agent_metadata: { model: "gpt-4" })
240
+ ```
241
+
242
+ ## License
243
+
244
+ MIT
data/Rakefile ADDED
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ task default: :spec
@@ -0,0 +1,154 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Ups
5
+ class AsyncReporter
6
+ CLOSED = :closed
7
+ OPEN = :open
8
+ HALF_OPEN = :half_open
9
+
10
+ def initialize(client, config)
11
+ @client = client
12
+ @config = config
13
+ @buffer = {}
14
+ @mutex = Mutex.new
15
+ @signal = Queue.new
16
+ @thread = nil
17
+ @shutdown = false
18
+ @at_exit_registered = false
19
+
20
+ # Circuit breaker state
21
+ @cb_state = CLOSED
22
+ @consecutive_failures = 0
23
+ @opened_at = nil
24
+ end
25
+
26
+ def enqueue(component_id, status:, agent_metadata:)
27
+ @mutex.synchronize do
28
+ @buffer[component_id] = { status: status, agent_metadata: agent_metadata }
29
+ end
30
+ ensure_running
31
+ @signal.push(:enqueue) rescue nil # wake the worker
32
+ end
33
+
34
+ def shutdown(timeout: 2)
35
+ @mutex.synchronize { @shutdown = true }
36
+ @signal.push(:shutdown) rescue nil
37
+ @thread&.join(timeout)
38
+ end
39
+
40
+ def running?
41
+ @thread&.alive? == true
42
+ end
43
+
44
+ def flush_now
45
+ entries = drain_buffer
46
+ flush_entries(entries) unless entries.empty?
47
+ end
48
+
49
+ private
50
+
51
+ def ensure_running
52
+ return if @thread&.alive?
53
+
54
+ @mutex.synchronize do
55
+ return if @thread&.alive?
56
+ @shutdown = false
57
+
58
+ @thread = Thread.new { worker_loop }
59
+ @thread.abort_on_exception = false
60
+
61
+ unless @at_exit_registered
62
+ at_exit { shutdown }
63
+ @at_exit_registered = true
64
+ end
65
+ end
66
+ end
67
+
68
+ def worker_loop
69
+ loop do
70
+ # Wait for signal or flush interval timeout
71
+ wait_for_signal
72
+
73
+ break if @mutex.synchronize { @shutdown }
74
+
75
+ entries = drain_buffer
76
+ flush_entries(entries) unless entries.empty?
77
+ end
78
+
79
+ # Final drain on shutdown
80
+ entries = drain_buffer
81
+ flush_entries(entries) unless entries.empty?
82
+ end
83
+
84
+ def wait_for_signal
85
+ deadline = Time.now + @config.flush_interval
86
+ loop do
87
+ remaining = deadline - Time.now
88
+ break if remaining <= 0
89
+
90
+ begin
91
+ # Non-blocking pop with short sleep fallback
92
+ @signal.pop(true)
93
+ break
94
+ rescue ThreadError
95
+ sleep(0.1)
96
+ end
97
+ end
98
+ end
99
+
100
+ def drain_buffer
101
+ @mutex.synchronize do
102
+ entries = @buffer.dup
103
+ @buffer.clear
104
+ entries
105
+ end
106
+ end
107
+
108
+ def flush_entries(entries)
109
+ return if circuit_open?
110
+
111
+ entries.each do |component_id, payload|
112
+ begin
113
+ @client.update_component(component_id, status: payload[:status],
114
+ agent_metadata: payload[:agent_metadata])
115
+ record_success
116
+ rescue => e
117
+ record_failure
118
+ break if circuit_open?
119
+ @config.on_error&.call(e)
120
+ end
121
+ end
122
+ end
123
+
124
+ def circuit_open?
125
+ case @cb_state
126
+ when CLOSED
127
+ false
128
+ when OPEN
129
+ if Time.now - @opened_at >= @config.circuit_breaker_timeout
130
+ @cb_state = HALF_OPEN
131
+ false
132
+ else
133
+ true
134
+ end
135
+ when HALF_OPEN
136
+ false
137
+ end
138
+ end
139
+
140
+ def record_success
141
+ @consecutive_failures = 0
142
+ @cb_state = CLOSED
143
+ end
144
+
145
+ def record_failure
146
+ @consecutive_failures += 1
147
+ if @consecutive_failures >= @config.circuit_breaker_threshold
148
+ @cb_state = OPEN
149
+ @opened_at = Time.now
150
+ end
151
+ end
152
+ end
153
+ end
154
+ end
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "faraday"
4
+ require "json"
5
+
6
+ module RubyLLM
7
+ module Ups
8
+ class Client
9
+ def initialize(config)
10
+ @config = config
11
+ @config.validate!
12
+ end
13
+
14
+ def update_component(component_id, status:, agent_metadata: {})
15
+ patch("components/#{component_id}", component: {
16
+ status: status,
17
+ agent_metadata: agent_metadata
18
+ })
19
+ end
20
+
21
+ def create_component(name:, component_type: "agent", **attrs)
22
+ post("components", component: { name: name, component_type: component_type, **attrs })
23
+ end
24
+
25
+ def list_components
26
+ get("components")
27
+ end
28
+
29
+ def create_incident(title:, impact:, status: :investigating, **attrs)
30
+ post("incidents", incident: {
31
+ title: title,
32
+ impact: impact,
33
+ status: status,
34
+ **attrs
35
+ })
36
+ end
37
+
38
+ def update_incident(id, **attrs)
39
+ patch("incidents/#{id}", incident: attrs)
40
+ end
41
+
42
+ def resolve_incident(id)
43
+ update_incident(id, status: :resolved)
44
+ end
45
+
46
+ private
47
+
48
+ def get(path)
49
+ handle_response(connection.get(api_path(path)))
50
+ end
51
+
52
+ def post(path, body)
53
+ handle_response(connection.post(api_path(path)) do |req|
54
+ req.body = JSON.generate(body)
55
+ end)
56
+ end
57
+
58
+ def patch(path, body)
59
+ handle_response(connection.patch(api_path(path)) do |req|
60
+ req.body = JSON.generate(body)
61
+ end)
62
+ end
63
+
64
+ def api_path(path)
65
+ "/api/v1/status_pages/#{@config.status_page_id}/#{path}"
66
+ end
67
+
68
+ def connection
69
+ @connection ||= Faraday.new(url: @config.base_url) do |f|
70
+ f.headers["Authorization"] = "Bearer #{@config.api_key}"
71
+ f.headers["Content-Type"] = "application/json"
72
+ f.headers["Accept"] = "application/json"
73
+ f.headers["User-Agent"] = "ruby_llm-ups/#{VERSION}"
74
+ f.options.timeout = @config.request_timeout
75
+ f.adapter Faraday.default_adapter
76
+ end
77
+ end
78
+
79
+ def handle_response(response)
80
+ body = parse_body(response.body)
81
+
82
+ case response.status
83
+ when 200..299
84
+ body
85
+ when 401
86
+ raise AuthenticationError.new("Invalid API token", status: response.status, body: body)
87
+ when 404
88
+ raise NotFoundError.new("Resource not found", status: response.status, body: body)
89
+ when 429
90
+ raise RateLimitError.new("Rate limit exceeded", status: response.status, body: body)
91
+ else
92
+ raise ApiError.new("API error", status: response.status, body: body)
93
+ end
94
+ end
95
+
96
+ def parse_body(body)
97
+ return {} if body.nil? || body.empty?
98
+
99
+ JSON.parse(body)
100
+ rescue JSON::ParserError
101
+ { "raw" => body }
102
+ end
103
+ end
104
+ end
105
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Ups
5
+ class ComponentRegistry
6
+ def initialize(client)
7
+ @client = client
8
+ @cache = {}
9
+ @mutex = Mutex.new
10
+ end
11
+
12
+ def find_or_create(name)
13
+ @mutex.synchronize { return @cache[name] if @cache[name] }
14
+
15
+ component = find_by_name(name) || create_component(name)
16
+
17
+ @mutex.synchronize { @cache[name] = component }
18
+ component
19
+ end
20
+
21
+ def clear_cache
22
+ @mutex.synchronize { @cache.clear }
23
+ end
24
+
25
+ private
26
+
27
+ def find_by_name(name)
28
+ response = @client.list_components
29
+ components = response["components"] || response
30
+ components = [components] if components.is_a?(Hash)
31
+
32
+ components.find { |c| c["name"] == name }
33
+ end
34
+
35
+ def create_component(name)
36
+ response = @client.create_component(name: name, component_type: "agent")
37
+ response["component"] || response
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Ups
5
+ class Configuration
6
+ attr_accessor :api_key, :base_url, :status_page_id, :component_id,
7
+ :request_timeout, :on_error,
8
+ :async, :flush_interval,
9
+ :circuit_breaker_threshold, :circuit_breaker_timeout
10
+
11
+ def initialize
12
+ @base_url = "https://ups.dev"
13
+ @request_timeout = 30
14
+ @on_error = ->(e) { $stderr.puts("[ruby_llm-ups] #{e.message}") }
15
+ @async = true
16
+ @flush_interval = 5
17
+ @circuit_breaker_threshold = 5
18
+ @circuit_breaker_timeout = 60
19
+ end
20
+
21
+ def validate!
22
+ raise ConfigurationError, "api_key is required" if api_key.nil? || api_key.empty?
23
+ raise ConfigurationError, "status_page_id is required" if status_page_id.nil? || status_page_id.empty?
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Ups
5
+ class Error < StandardError; end
6
+ class ConfigurationError < Error; end
7
+
8
+ class ApiError < Error
9
+ attr_reader :status, :body
10
+
11
+ def initialize(message = nil, status: nil, body: nil)
12
+ @status = status
13
+ @body = body
14
+ super(message || "API error (#{status})")
15
+ end
16
+ end
17
+
18
+ class AuthenticationError < ApiError; end
19
+ class NotFoundError < ApiError; end
20
+ class RateLimitError < ApiError; end
21
+ end
22
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Ups
5
+ module IncidentMethods
6
+ def create_incident(title:, impact:, status: :investigating, **attrs)
7
+ meta = _ups_monitor.build_metadata
8
+ meta[:component_id] ||= _ups_monitor.component_id
9
+ _ups_monitor.client.create_incident(title: title, impact: impact, status: status,
10
+ agent_metadata: meta, **attrs)
11
+ end
12
+
13
+ def update_incident(id, **attrs)
14
+ caller_meta = attrs.delete(:agent_metadata) || {}
15
+ meta = _ups_monitor.build_metadata.merge(caller_meta)
16
+ _ups_monitor.client.update_incident(id, agent_metadata: meta, **attrs)
17
+ end
18
+
19
+ def resolve_incident(id)
20
+ update_incident(id, status: :resolved)
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Ups
5
+ class Monitor
6
+ attr_reader :component_id, :client
7
+
8
+ def initialize(target, component_id:, client:, async_reporter: nil)
9
+ @chat = extract_chat(target)
10
+ @component_id = component_id
11
+ @client = client
12
+ @async_reporter = async_reporter
13
+ @request_start = nil
14
+ install_callbacks
15
+ install_incident_methods(target)
16
+ end
17
+
18
+ def mark_start
19
+ @request_start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
20
+ end
21
+
22
+ def report(_message)
23
+ if @async_reporter
24
+ @async_reporter.enqueue(@component_id, status: :operational, agent_metadata: build_metadata)
25
+ else
26
+ @client.update_component(@component_id, status: :operational, agent_metadata: build_metadata)
27
+ end
28
+ rescue => e
29
+ handle_error(e)
30
+ end
31
+
32
+ def build_metadata
33
+ metadata = {
34
+ model: @chat.model.id,
35
+ provider: @chat.model.provider.to_s,
36
+ tool_count: @chat.tools.size
37
+ }
38
+
39
+ if @request_start
40
+ elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - @request_start
41
+ metadata[:last_response_time_ms] = (elapsed * 1000).round
42
+ end
43
+
44
+ metadata
45
+ end
46
+
47
+ private
48
+
49
+ def extract_chat(target)
50
+ target.respond_to?(:chat) ? target.chat : target
51
+ end
52
+
53
+ def install_callbacks
54
+ monitor = self
55
+
56
+ @chat.on_new_message do
57
+ monitor.mark_start
58
+ end
59
+
60
+ @chat.on_end_message do |msg|
61
+ monitor.report(msg) if msg.role == :assistant
62
+ end
63
+ end
64
+
65
+ def install_incident_methods(target)
66
+ target.instance_variable_set(:@_ups_monitor, self)
67
+ target.define_singleton_method(:_ups_monitor) { @_ups_monitor } unless target.respond_to?(:_ups_monitor)
68
+ target.extend(IncidentMethods)
69
+ end
70
+
71
+ def handle_error(error)
72
+ Ups.config.on_error.call(error)
73
+ end
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Ups
5
+ module Monitored
6
+ def self.included(base)
7
+ base.extend(ClassMethods)
8
+ base.prepend(Initializer)
9
+ end
10
+
11
+ module ClassMethods
12
+ def ups_component(name = nil)
13
+ if name
14
+ @_ups_component_name = name
15
+ else
16
+ @_ups_component_name
17
+ end
18
+ end
19
+
20
+ def inherited(subclass)
21
+ super
22
+ subclass.instance_variable_set(:@_ups_component_name, @_ups_component_name)
23
+ end
24
+ end
25
+
26
+ module Initializer
27
+ def initialize(*, **kwargs, &block)
28
+ super
29
+ _ups_auto_monitor
30
+ end
31
+
32
+ private
33
+
34
+ def _ups_auto_monitor
35
+ return unless _ups_configured?
36
+
37
+ name = self.class.ups_component || _ups_derive_name
38
+ component = RubyLLM::Ups.component_registry.find_or_create(name)
39
+ component_id = component["id"]
40
+
41
+ RubyLLM::Ups.monitor(self, component_id: component_id)
42
+ rescue => e
43
+ RubyLLM::Ups.config.on_error&.call(e)
44
+ end
45
+
46
+ def _ups_configured?
47
+ config = RubyLLM::Ups.config
48
+ config.api_key && !config.api_key.to_s.empty? &&
49
+ config.status_page_id && !config.status_page_id.to_s.empty?
50
+ end
51
+
52
+ def _ups_derive_name
53
+ class_name = self.class.name&.split("::")&.last || self.class.to_s
54
+ # Insert space before capitals: "ResearchAgent" -> "Research Agent"
55
+ name = class_name.gsub(/([a-z])([A-Z])/, '\1 \2')
56
+ # Append "Agent" if not already present
57
+ name += " Agent" unless name =~ /Agent\s*$/i
58
+ name
59
+ end
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Ups
5
+ class Railtie < Rails::Railtie
6
+ initializer "ruby_llm_ups.configure" do
7
+ config = RubyLLM::Ups.config
8
+ creds = Rails.application.credentials.dig(:ups) || {}
9
+
10
+ config.api_key ||= creds[:api_key]
11
+ config.status_page_id ||= creds[:status_page_id]
12
+ config.component_id ||= creds[:component_id]
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Ups
5
+ VERSION = "0.1.0"
6
+ end
7
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "ups/version"
4
+ require_relative "ups/errors"
5
+ require_relative "ups/configuration"
6
+ require_relative "ups/client"
7
+ require_relative "ups/async_reporter"
8
+ require_relative "ups/incident_methods"
9
+ require_relative "ups/monitor"
10
+ require_relative "ups/component_registry"
11
+ require_relative "ups/monitored"
12
+
13
+ module RubyLLM
14
+ module Ups
15
+ class << self
16
+ def configure
17
+ yield config
18
+ @client = nil # reset client on reconfigure
19
+ @async_reporter = nil
20
+ @component_registry = nil
21
+ end
22
+
23
+ def config
24
+ @config ||= Configuration.new
25
+ end
26
+
27
+ def client
28
+ @client ||= Client.new(config)
29
+ end
30
+
31
+ def component_registry
32
+ @component_registry ||= ComponentRegistry.new(client)
33
+ end
34
+
35
+ def monitor(chat_or_agent, component_id: nil)
36
+ cid = resolve_component_id!(component_id)
37
+ Monitor.new(chat_or_agent, component_id: cid, client: client,
38
+ async_reporter: config.async ? async_reporter : nil)
39
+ chat_or_agent
40
+ end
41
+
42
+ def report_status(status, component_id: nil, agent_metadata: {})
43
+ cid = resolve_component_id!(component_id)
44
+ client.update_component(cid, status: status, agent_metadata: agent_metadata)
45
+ end
46
+
47
+ def create_incident(title:, impact:, status: :investigating, **attrs)
48
+ client.create_incident(title: title, impact: impact, status: status, **attrs)
49
+ end
50
+
51
+ def update_incident(id, **attrs)
52
+ client.update_incident(id, **attrs)
53
+ end
54
+
55
+ def resolve_incident(id)
56
+ client.resolve_incident(id)
57
+ end
58
+
59
+ def reset!
60
+ @async_reporter&.shutdown
61
+ @config = nil
62
+ @client = nil
63
+ @async_reporter = nil
64
+ @component_registry = nil
65
+ end
66
+
67
+ private
68
+
69
+ def async_reporter
70
+ @async_reporter ||= AsyncReporter.new(client, config)
71
+ end
72
+
73
+ def resolve_component_id!(component_id = nil)
74
+ cid = component_id || config.component_id
75
+ raise ConfigurationError, "component_id is required" if cid.nil? || cid.to_s.empty?
76
+
77
+ cid
78
+ end
79
+ end
80
+ end
81
+ end
82
+
83
+ require_relative "ups/railtie" if defined?(Rails::Railtie)
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "ruby_llm/ups"
metadata ADDED
@@ -0,0 +1,100 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: ruby_llm-ups
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Valentino Stoll
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: ruby_llm
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: 1.13.0
19
+ - - "<"
20
+ - !ruby/object:Gem::Version
21
+ version: '3.0'
22
+ type: :runtime
23
+ prerelease: false
24
+ version_requirements: !ruby/object:Gem::Requirement
25
+ requirements:
26
+ - - ">="
27
+ - !ruby/object:Gem::Version
28
+ version: 1.13.0
29
+ - - "<"
30
+ - !ruby/object:Gem::Version
31
+ version: '3.0'
32
+ - !ruby/object:Gem::Dependency
33
+ name: faraday
34
+ requirement: !ruby/object:Gem::Requirement
35
+ requirements:
36
+ - - ">="
37
+ - !ruby/object:Gem::Version
38
+ version: '1.0'
39
+ - - "<"
40
+ - !ruby/object:Gem::Version
41
+ version: '3.0'
42
+ type: :runtime
43
+ prerelease: false
44
+ version_requirements: !ruby/object:Gem::Requirement
45
+ requirements:
46
+ - - ">="
47
+ - !ruby/object:Gem::Version
48
+ version: '1.0'
49
+ - - "<"
50
+ - !ruby/object:Gem::Version
51
+ version: '3.0'
52
+ description: Automatically report AI agent health, response times, and model info
53
+ to your ups.dev status page.
54
+ email:
55
+ - v@codenamev.com
56
+ executables: []
57
+ extensions: []
58
+ extra_rdoc_files: []
59
+ files:
60
+ - CHANGELOG.md
61
+ - LICENSE.txt
62
+ - README.md
63
+ - Rakefile
64
+ - lib/ruby_llm-ups.rb
65
+ - lib/ruby_llm/ups.rb
66
+ - lib/ruby_llm/ups/async_reporter.rb
67
+ - lib/ruby_llm/ups/client.rb
68
+ - lib/ruby_llm/ups/component_registry.rb
69
+ - lib/ruby_llm/ups/configuration.rb
70
+ - lib/ruby_llm/ups/errors.rb
71
+ - lib/ruby_llm/ups/incident_methods.rb
72
+ - lib/ruby_llm/ups/monitor.rb
73
+ - lib/ruby_llm/ups/monitored.rb
74
+ - lib/ruby_llm/ups/railtie.rb
75
+ - lib/ruby_llm/ups/version.rb
76
+ homepage: https://github.com/codenamev/ruby_llm-ups
77
+ licenses:
78
+ - MIT
79
+ metadata:
80
+ homepage_uri: https://github.com/codenamev/ruby_llm-ups
81
+ source_code_uri: https://github.com/codenamev/ruby_llm-ups
82
+ changelog_uri: https://github.com/codenamev/ruby_llm-ups/blob/main/CHANGELOG.md
83
+ rdoc_options: []
84
+ require_paths:
85
+ - lib
86
+ required_ruby_version: !ruby/object:Gem::Requirement
87
+ requirements:
88
+ - - ">="
89
+ - !ruby/object:Gem::Version
90
+ version: 3.1.0
91
+ required_rubygems_version: !ruby/object:Gem::Requirement
92
+ requirements:
93
+ - - ">="
94
+ - !ruby/object:Gem::Version
95
+ version: '0'
96
+ requirements: []
97
+ rubygems_version: 4.0.6
98
+ specification_version: 4
99
+ summary: ups.dev integration for RubyLLM
100
+ test_files: []