agent-harness 0.30.0 → 0.33.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.
@@ -17,6 +17,14 @@ module AgentHarness
17
17
  include RateLimitResetParsing
18
18
  include McpConfigFileSupport
19
19
 
20
+ # Anthropic rate-limit response headers exposing quota/rate information.
21
+ # These are surfaced on normal /v1/messages responses and parsed by
22
+ # {#update_quota_from_headers} to opportunistically refresh cached quota
23
+ # without making a dedicated API call.
24
+ RATE_LIMIT_HEADER_LIMIT = "anthropic-ratelimit-tokens-limit"
25
+ RATE_LIMIT_HEADER_REMAINING = "anthropic-ratelimit-tokens-remaining"
26
+ RATE_LIMIT_HEADER_RESET = "anthropic-ratelimit-tokens-reset"
27
+
20
28
  # Model name pattern for Anthropic Claude models
21
29
  MODEL_PATTERN = /^claude-[\d.-]+-(?:opus|sonnet|haiku)(?:-\d{8})?$/i
22
30
  SUPPORTED_CLI_VERSION = "2.1.92"
@@ -411,6 +419,25 @@ module AgentHarness
411
419
 
412
420
  def subscription_unset_vars = ["ANTHROPIC_API_KEY", "ANTHROPIC_BASE_URL"] + api_key_unset_vars
413
421
 
422
+ # Opportunistically refresh quota info from Anthropic rate-limit headers
423
+ # observed on a normal /v1/messages response.
424
+ #
425
+ # @param headers [Hash{String=>String}, Net::HTTPHeader] response headers
426
+ # @return [AgentHarness::QuotaStatus, nil]
427
+ def update_quota_from_headers(headers)
428
+ limit_value = header_value(headers, RATE_LIMIT_HEADER_LIMIT)
429
+ remaining_value = header_value(headers, RATE_LIMIT_HEADER_REMAINING)
430
+ return nil unless limit_value || remaining_value
431
+
432
+ QuotaStatus.new(
433
+ available: true,
434
+ remaining: remaining_value&.to_i,
435
+ limit: limit_value&.to_i,
436
+ reset_at: parse_rate_limit_header_reset(headers),
437
+ unit: :tokens
438
+ )
439
+ end
440
+
414
441
  def supports_mcp?
415
442
  true
416
443
  end
@@ -678,6 +705,7 @@ module AgentHarness
678
705
  kwargs[:max_tokens] = max_tokens if max_tokens
679
706
 
680
707
  response = transport.send_message(prompt, **kwargs)
708
+ response = attach_quota_status_from_headers(response)
681
709
 
682
710
  # Apply runtime model override if present
683
711
  runtime = options[:provider_runtime]
@@ -814,6 +842,26 @@ module AgentHarness
814
842
  def log_debug(action, **context)
815
843
  @logger&.debug("[AgentHarness::Anthropic] #{action}: #{context.inspect}")
816
844
  end
845
+
846
+ # Read a header value from either a Net::HTTPHeader or a plain Hash.
847
+ def header_value(headers, name)
848
+ return headers[name] || headers[name.to_sym] if headers.respond_to?(:[])
849
+
850
+ nil
851
+ rescue NoMethodError
852
+ nil
853
+ end
854
+
855
+ # Anthropic's ratelimit reset header is an RFC 3339 timestamp
856
+ # (e.g. "2026-07-21T05:00:00Z"), per the API docs.
857
+ def parse_rate_limit_header_reset(headers)
858
+ raw = header_value(headers, RATE_LIMIT_HEADER_RESET)
859
+ return nil unless raw
860
+
861
+ Time.iso8601(raw.to_s).utc
862
+ rescue ArgumentError
863
+ nil
864
+ end
817
865
  end
818
866
  end
819
867
  end
@@ -322,6 +322,7 @@ module AgentHarness
322
322
  &on_chunk
323
323
  )
324
324
 
325
+ response = attach_quota_status_from_headers(response)
325
326
  response = apply_extensions_after_response(extension_context, response)
326
327
 
327
328
  track_tokens(response) if response.tokens
@@ -447,6 +448,39 @@ module AgentHarness
447
448
  {healthy: true}
448
449
  end
449
450
 
451
+ # Proactively query remaining provider quota for the current billing
452
+ # period without running a full agent.
453
+ #
454
+ # The default implementation reports that quota checking is unavailable
455
+ # so callers can fall back to {AgentHarness::TokenUsageTracker}. Providers
456
+ # that expose a usage/quota API override this to return a populated
457
+ # {QuotaStatus}.
458
+ #
459
+ # The +env+ hash carries the same provider credentials and overrides that
460
+ # {send_message} uses (typically derived from +ProviderRuntime#env+), so
461
+ # the quota check can reuse the same authentication as a normal run.
462
+ #
463
+ # @param env [Hash{String=>String}] request-scoped environment overrides
464
+ # @param timeout [Numeric] time budget in seconds
465
+ # @return [AgentHarness::QuotaStatus]
466
+ def check_quota(env:, timeout: 10)
467
+ QuotaStatus.unavailable
468
+ end
469
+
470
+ # Opportunistically update cached quota info from rate-limit headers
471
+ # observed during a normal {send_message} flow.
472
+ #
473
+ # Providers that emit +x-ratelimit-*+ style headers should override this
474
+ # to parse them into a {QuotaStatus} and return it. The base
475
+ # implementation is a no-op so providers that do not expose headers (or
476
+ # runs where the information is unavailable) are handled gracefully.
477
+ #
478
+ # @param headers [Hash{String=>String}, Net::HTTPHeader] response headers
479
+ # @return [AgentHarness::QuotaStatus, nil]
480
+ def update_quota_from_headers(headers)
481
+ nil
482
+ end
483
+
450
484
  protected
451
485
 
452
486
  # Build CLI command - override in subclasses
@@ -1019,6 +1053,29 @@ module AgentHarness
1019
1053
  output_tokens: response.tokens[:output] || 0,
1020
1054
  total_tokens: response.tokens[:total]
1021
1055
  )
1056
+
1057
+ # Feed the same usage into the fallback quota tracker so providers
1058
+ # without a proactive quota API can still estimate remaining quota from
1059
+ # a caller-configured billing limit. The record signatures intentionally
1060
+ # match so the two trackers can share this hook.
1061
+ AgentHarness.token_usage_tracker.record(
1062
+ provider: self.class.provider_name,
1063
+ model: response.model || @config.model,
1064
+ input_tokens: response.tokens[:input] || 0,
1065
+ output_tokens: response.tokens[:output] || 0,
1066
+ total_tokens: response.tokens[:total]
1067
+ )
1068
+ end
1069
+
1070
+ def attach_quota_status_from_headers(response)
1071
+ headers = response.metadata[:headers]
1072
+ return response unless headers
1073
+
1074
+ quota_status = update_quota_from_headers(headers)
1075
+ return response unless quota_status
1076
+
1077
+ response.metadata[:quota_status] = quota_status
1078
+ response
1022
1079
  end
1023
1080
 
1024
1081
  def handle_error(error, prompt:, options:)
@@ -661,6 +661,26 @@ module AgentHarness
661
661
 
662
662
  def cli_env_overrides = {"PAID_CODEX_SUBSCRIPTION_AUTH" => "1"}
663
663
 
664
+ # Proactively check quota for Codex's configured backend.
665
+ #
666
+ # Codex runners frequently route through OpenRouter by setting
667
+ # +OPENAI_BASE_URL=https://openrouter.ai/api/v1+. When the request env
668
+ # indicates OpenRouter, the check is delegated to
669
+ # {QuotaCheckers::OpenRouter}, which queries the +/credits+ endpoint.
670
+ # Otherwise OpenAI's quota API is not publicly exposed, so the check
671
+ # returns unavailable and callers fall back to {TokenUsageTracker}.
672
+ #
673
+ # @param env [Hash{String=>String}] request-scoped environment
674
+ # @param timeout [Numeric] time budget in seconds
675
+ # @return [AgentHarness::QuotaStatus]
676
+ def check_quota(env:, timeout: QuotaCheckers::OpenRouter::DEFAULT_TIMEOUT)
677
+ if QuotaCheckers::OpenRouter.routes_through_open_router?(env)
678
+ return QuotaCheckers::OpenRouter.check(env: env, timeout: timeout, logger: @logger)
679
+ end
680
+
681
+ QuotaStatus.unavailable
682
+ end
683
+
664
684
  def send_message(prompt:, **options)
665
685
  super
666
686
  ensure
@@ -10,9 +10,36 @@ module AgentHarness
10
10
  # Provides integration with the Kilocode CLI tool.
11
11
  class Kilocode < Base
12
12
  PACKAGE_NAME = "@kilocode/cli"
13
- DEFAULT_VERSION = "7.1.3"
13
+ DEFAULT_VERSION = "7.4.16"
14
14
  SUPPORTED_VERSION_REQUIREMENT = "= #{DEFAULT_VERSION}"
15
15
  STRUCTURED_EVENT_TYPES = %w[text error step_finish result usage].freeze
16
+ MODEL_NAMES = %w[
17
+ glm-5
18
+ glm-5.1
19
+ glm-5.1-free
20
+ glm-5.1-thinking
21
+ glm-5.2
22
+ glm-5.2-fast
23
+ glm-5.2-flex
24
+ glm-5.2-free
25
+ glm-5.2-nitro
26
+ glm-5.2-short
27
+ glm-5.2-short-fast
28
+ glm-5.2-short-fast-flex
29
+ glm-5.2-short-flex
30
+ glm-5p1
31
+ glm-5p1-fast
32
+ glm-5p2
33
+ glm-5p2-fast
34
+ glm-5v-turbo
35
+ ].freeze
36
+ MODEL_CATALOG = MODEL_NAMES.map do |name|
37
+ tier = name.include?("free") ? "free" : "standard"
38
+ tier = "advanced" if %w[glm-5.1 glm-5.1-thinking glm-5.2 glm-5p1 glm-5p2].include?(name)
39
+
40
+ {name: name, family: name, tier: tier, provider: "kilocode"}.freeze
41
+ end.freeze
42
+ MODEL_FAMILY_PATTERN = /\Aglm-5(?:[a-z][\w.-]*|[.-][\w.-]+)?\z/i
16
43
  # Kilo CLI (an OpenCode fork) ships the same external_directory
17
44
  # permission category as OpenCode, defaulting to "ask" for anything
18
45
  # outside the project dir. In non-interactive execution there is no
@@ -73,7 +100,20 @@ module AgentHarness
73
100
 
74
101
  def discover_models
75
102
  return [] unless available?
76
- []
103
+
104
+ MODEL_CATALOG.map(&:dup)
105
+ end
106
+
107
+ def model_family(provider_model_name)
108
+ provider_model_name.to_s
109
+ end
110
+
111
+ def provider_model_name(family_name)
112
+ family_name.to_s
113
+ end
114
+
115
+ def supports_model_family?(family_name)
116
+ MODEL_FAMILY_PATTERN.match?(family_name.to_s)
77
117
  end
78
118
 
79
119
  def installation_contract(version: DEFAULT_VERSION)
@@ -379,23 +419,83 @@ module AgentHarness
379
419
 
380
420
  private
381
421
 
382
- # Default-merge a permissive external_directory permission into the
383
- # generated Kilo config unless the caller takes responsibility for
384
- # permission config. A caller-supplied permission (passed via the
385
- # +:permission+ option or already present on the config under the
386
- # +"permission"+ key, e.g. from runtime metadata config extras) is
387
- # honored verbatim and never overridden, and an invalid or empty
388
- # caller permission is ignored in favor of the default rule.
422
+ # Default-merge the permissive external_directory permission into the
423
+ # generated Kilo config. A caller that supplies a +permission+ block is
424
+ # merged ON TOP of the provider's non-interactive defaults (the container
425
+ # scratch + home-directory allowlist): the default +external_directory+
426
+ # entries are unioned with the caller's +external_directory+ entries (the
427
+ # caller wins on conflicting patterns) and any other caller-supplied
428
+ # permission category is carried through verbatim. This makes it hard to
429
+ # accidentally discard the provider defaults when a caller only needs to
430
+ # add a single +external_directory+ entry (see #310).
431
+ #
432
+ # A caller that intentionally owns the full permission block (e.g. to deny
433
+ # the default /tmp or home paths) can opt out of the merge with the
434
+ # +:permission_replace+ (or +"permission_replace"+) option, in which case
435
+ # the caller-supplied permission is honored verbatim and no defaults are
436
+ # injected. If +permission_replace+ is set but the caller does not supply
437
+ # a +permission+ key at all, the config is left without any injected
438
+ # +permission+ block.
439
+ #
440
+ # An invalid caller permission is ignored in favor of the default rule.
441
+ # An empty caller permission hash also falls back to the default rule,
442
+ # unless +permission_replace+ is set, in which case the empty hash is
443
+ # preserved verbatim.
389
444
  def apply_default_external_directory_permission(config, options = {})
445
+ if options[:permission_replace] || options["permission_replace"]
446
+ if options.key?(:permission)
447
+ config["permission"] = deep_dup(options[:permission])
448
+ elsif options.key?("permission")
449
+ config["permission"] = deep_dup(options["permission"])
450
+ else
451
+ config.delete("permission")
452
+ end
453
+ return
454
+ end
455
+
390
456
  caller_permission = options[:permission] || options["permission"] || config["permission"]
457
+
391
458
  if caller_permission.is_a?(Hash) && !caller_permission.empty?
392
- config["permission"] = deep_dup(caller_permission)
459
+ config["permission"] = merge_default_permission(caller_permission)
393
460
  return
394
461
  end
395
462
 
396
463
  config["permission"] = deep_dup(DEFAULT_PERMISSION_CONFIG)
397
464
  end
398
465
 
466
+ # Merge a caller-supplied permission block on top of the provider default
467
+ # permission rule. The default +external_directory+ allowlist is unioned
468
+ # with the caller's +external_directory+ entries (caller decisions win on
469
+ # conflicting patterns); every other caller-supplied permission category
470
+ # is carried through verbatim.
471
+ def merge_default_permission(caller_permission)
472
+ merged = deep_dup(DEFAULT_PERMISSION_CONFIG)
473
+
474
+ caller_permission.each do |category, rules|
475
+ if category.to_s == "external_directory"
476
+ merged["external_directory"] = merge_external_directory_rules(merged["external_directory"], rules)
477
+ else
478
+ merged[category.to_s] = deep_dup(rules)
479
+ end
480
+ end
481
+
482
+ merged
483
+ end
484
+
485
+ # Union default external_directory rules with caller-supplied rules.
486
+ # Caller decisions win on overlapping patterns. A non-Hash caller value
487
+ # (e.g. a scalar default) cannot be meaningfully merged, so it is honored
488
+ # verbatim for that category.
489
+ def merge_external_directory_rules(default_rules, caller_rules)
490
+ return deep_dup(caller_rules) unless caller_rules.is_a?(Hash)
491
+
492
+ rules = deep_dup(default_rules)
493
+ caller_rules.each do |pattern, decision|
494
+ rules[pattern.to_s] = deep_dup(decision)
495
+ end
496
+ rules
497
+ end
498
+
399
499
  def kilocode_config_path
400
500
  "~/.config/kilocode/kilo.json"
401
501
  end
@@ -410,7 +510,10 @@ module AgentHarness
410
510
 
411
511
  payload = stringify_keys(config_extras)
412
512
  payload["model"] = runtime.model if runtime.model
413
- apply_default_external_directory_permission(payload)
513
+ apply_default_external_directory_permission(payload, payload)
514
+ # +permission_replace+ is a control flag for the merge above, not a Kilo
515
+ # config key, so strip it before the payload is serialized to disk.
516
+ payload.delete("permission_replace")
414
517
  payload.empty? ? nil : payload
415
518
  end
416
519
 
@@ -10,8 +10,8 @@ module AgentHarness
10
10
  # Provides integration with the OpenCode CLI tool.
11
11
  class Opencode < Base
12
12
  CLI_PACKAGE = "opencode-ai"
13
- SUPPORTED_CLI_VERSION = "1.3.2"
14
- SUPPORTED_CLI_REQUIREMENT = Gem::Requirement.new(">= #{SUPPORTED_CLI_VERSION}", "< 1.4.0").freeze
13
+ SUPPORTED_CLI_VERSION = "1.18.9"
14
+ SUPPORTED_CLI_REQUIREMENT = Gem::Requirement.new(">= #{SUPPORTED_CLI_VERSION}", "< 2.0.0").freeze
15
15
  INSTALL_COMMAND_PREFIX = ["npm", "install", "-g", "--ignore-scripts"].freeze
16
16
  # Allowlist of external_directory patterns auto-approved in
17
17
  # non-interactive execution. See the DEFAULT_PERMISSION_RULE comment
@@ -0,0 +1,154 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "json"
5
+ require "uri"
6
+
7
+ module AgentHarness
8
+ module Providers
9
+ # Standalone quota-check helpers for backends that do not have a dedicated
10
+ # provider class but can still be queried proactively before a run starts.
11
+ #
12
+ # Today this hosts the OpenRouter credits lookup, which Paid's runners can
13
+ # route through even though there is no canonical +:openrouter+ provider
14
+ # class (it is selected via +ProviderRuntime#api_provider+). Other backends
15
+ # that surface through OpenAI-compatible transports but expose their own
16
+ # balance endpoint belong here too.
17
+ module QuotaCheckers
18
+ # Quota checker for OpenRouter's credit balance API.
19
+ #
20
+ # OpenRouter is selected by Paid runners via +ProviderRuntime+ overrides
21
+ # rather than a dedicated provider class, so the check is implemented as
22
+ # a standalone helper that any provider can call from its own
23
+ # +check_quota+ implementation when the request env points at OpenRouter.
24
+ #
25
+ # @example Direct usage
26
+ # AgentHarness::Providers::QuotaCheckers::OpenRouter.check(
27
+ # env: { "OPENROUTER_API_KEY" => "sk-..." }
28
+ # )
29
+ # # => #<AgentHarness::QuotaStatus available=true remaining=12.5 unit=:credits ...>
30
+ module OpenRouter
31
+ DEFAULT_BASE_URL = "https://openrouter.ai/api/v1"
32
+ DEFAULT_TIMEOUT = 10
33
+ USER_AGENT = "AgentHarness/1.0"
34
+ HOST_FRAGMENT = "openrouter.ai"
35
+
36
+ class << self
37
+ # Detect whether a request env would route through OpenRouter.
38
+ #
39
+ # Used by providers (Codex, Kilocode, etc.) to decide whether to
40
+ # delegate +check_quota+ to this checker.
41
+ #
42
+ # @param env [Hash{String=>String}] request-scoped environment
43
+ # @return [Boolean]
44
+ def routes_through_open_router?(env)
45
+ return false if env.nil?
46
+
47
+ values = env.values_at("OPENROUTER_API_KEY", :OPENROUTER_API_KEY)
48
+ return true if values.any? { |value| value.respond_to?(:to_str) && !value.to_str.empty? }
49
+
50
+ base_url_values = env.values_at("OPENAI_BASE_URL", :OPENAI_BASE_URL)
51
+ base_url_values.any? { |value| value.to_s.include?(HOST_FRAGMENT) }
52
+ end
53
+
54
+ # Resolve the API key the OpenRouter quota endpoint should use.
55
+ #
56
+ # Order of precedence: explicit +OPENROUTER_API_KEY+, then
57
+ # +OPENAI_API_KEY+ (since OpenRouter accepts it under that name when
58
+ # routed via the OpenAI-compatible transport).
59
+ #
60
+ # @param env [Hash{String=>String}] request-scoped environment
61
+ # @return [String, nil]
62
+ def resolve_api_key(env)
63
+ env_value(env, "OPENROUTER_API_KEY") ||
64
+ env_value(env, "OPENAI_API_KEY")
65
+ end
66
+
67
+ # Query OpenRouter's +/credits+ endpoint and return a {QuotaStatus}.
68
+ #
69
+ # @param env [Hash{String=>String}] request-scoped environment
70
+ # @param base_url [String, nil] override the API base URL
71
+ # @param timeout [Numeric] time budget in seconds
72
+ # @param logger [Logger, nil]
73
+ # @return [AgentHarness::QuotaStatus] populated when credentials are
74
+ # present and the API responds; unavailable otherwise
75
+ def check(env:, base_url: nil, timeout: DEFAULT_TIMEOUT, logger: nil)
76
+ api_key = resolve_api_key(env)
77
+ unless api_key && !api_key.empty?
78
+ logger&.debug("[AgentHarness::QuotaCheckers::OpenRouter] no API key present in env")
79
+ return QuotaStatus.unavailable
80
+ end
81
+
82
+ uri = URI.parse("#{resolve_base_url(env, base_url)}/credits")
83
+ response = perform_get(uri:, api_key:, timeout:, logger:)
84
+ return QuotaStatus.unavailable unless response
85
+
86
+ parse_credits_response(response)
87
+ rescue IOError, SocketError, SystemCallError, Timeout::Error, JSON::ParserError,
88
+ OpenSSL::SSL::SSLError => e
89
+ logger&.warn("[AgentHarness::QuotaCheckers::OpenRouter] credit check failed: #{e.message}")
90
+ QuotaStatus.unavailable
91
+ end
92
+
93
+ private
94
+
95
+ def resolve_base_url(env, override)
96
+ base = override || env_value(env, "OPENROUTER_BASE_URL") || DEFAULT_BASE_URL
97
+ base.to_s.chomp("/")
98
+ end
99
+
100
+ def env_value(env, key)
101
+ return nil if env.nil?
102
+
103
+ env[key] || env[key.to_sym]
104
+ end
105
+
106
+ def perform_get(uri:, api_key:, timeout:, logger:)
107
+ http = Net::HTTP.new(uri.host, uri.port)
108
+ http.use_ssl = (uri.scheme == "https")
109
+ http.open_timeout = timeout
110
+ http.read_timeout = timeout
111
+
112
+ request = Net::HTTP::Get.new(uri)
113
+ request["Authorization"] = "Bearer #{api_key}"
114
+ request["User-Agent"] = USER_AGENT
115
+
116
+ logger&.debug("[AgentHarness::QuotaCheckers::OpenRouter] GET #{uri}")
117
+
118
+ response = http.request(request)
119
+ return response if response.is_a?(Net::HTTPSuccess)
120
+
121
+ logger&.warn("[AgentHarness::QuotaCheckers::OpenRouter] #{uri} returned HTTP #{response.code}")
122
+ nil
123
+ end
124
+
125
+ # OpenRouter returns: {"data": {"total_credits": 20.0, "total_usage": 7.5}}
126
+ def parse_credits_response(response)
127
+ body = response.body
128
+ return QuotaStatus.unavailable if body.nil? || body.empty?
129
+
130
+ parsed = JSON.parse(body)
131
+ data = parsed.is_a?(Hash) ? parsed["data"] : nil
132
+ return QuotaStatus.unavailable unless data.is_a?(Hash)
133
+
134
+ total = data["total_credits"]
135
+ usage = data["total_usage"]
136
+ return QuotaStatus.unavailable if total.nil? || usage.nil?
137
+
138
+ limit = total.to_f
139
+ used = usage.to_f
140
+ remaining = limit - used
141
+
142
+ QuotaStatus.new(
143
+ available: true,
144
+ remaining: remaining,
145
+ limit: limit,
146
+ reset_at: nil,
147
+ unit: :credits
148
+ )
149
+ end
150
+ end
151
+ end
152
+ end
153
+ end
154
+ end
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "quota_checkers/open_router"
@@ -0,0 +1,175 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module AgentHarness
6
+ # Serializable snapshot of a provider's remaining quota for the current
7
+ # billing period.
8
+ #
9
+ # {QuotaStatus} is the unified return type for {Providers::Base#check_quota}
10
+ # and {TokenUsageTracker#estimated_usage}. It lets the orchestration layer
11
+ # compare runners on the same axis (remaining/limit/unit) so Paid can
12
+ # auto-balance weights and let multiple runners exhaust their quotas at
13
+ # roughly the same rate.
14
+ #
15
+ # All instances are frozen so callers can safely cache, share, and persist
16
+ # them (for example, to a database column) without worrying about mutation.
17
+ #
18
+ # @example A real API-backed status
19
+ # AgentHarness::QuotaStatus.new(
20
+ # available: true,
21
+ # remaining: 1_500_000,
22
+ # limit: 2_000_000,
23
+ # reset_at: Time.utc(2026, 8, 1),
24
+ # unit: :tokens
25
+ # )
26
+ #
27
+ # @example Sentinel returned by providers that do not expose a quota API
28
+ # AgentHarness::QuotaStatus.unavailable
29
+ #
30
+ class QuotaStatus < Struct.new(
31
+ :available,
32
+ :remaining,
33
+ :limit,
34
+ :reset_at,
35
+ :unit,
36
+ :checked_at
37
+ )
38
+ # Units recognized by the harness. Providers may return other Symbols,
39
+ # but the documented set is enumerated here so callers can switch on it
40
+ # without typos. New units should be added here when a provider exposes
41
+ # one that does not fit an existing bucket.
42
+ UNITS = %i[
43
+ tokens
44
+ requests
45
+ credits
46
+ cost_cents
47
+ ].freeze
48
+
49
+ def initialize(available: false, remaining: nil, limit: nil, reset_at: nil, unit: nil, checked_at: nil)
50
+ unless reset_at.nil? || reset_at.is_a?(Time)
51
+ raise ArgumentError, "reset_at must be a Time or nil (got #{reset_at.class})"
52
+ end
53
+ unless unit.nil? || unit.is_a?(Symbol)
54
+ raise ArgumentError, "unit must be a Symbol or nil (got #{unit.class})"
55
+ end
56
+ unless available == true || available == false
57
+ raise ArgumentError, "available must be a boolean (got #{available.class})"
58
+ end
59
+
60
+ # Snap the check time when an available status is built without one so
61
+ # cached/stored statuses always record when the underlying lookup ran.
62
+ resolved_checked_at = checked_at || (available ? Time.now.utc : nil)
63
+
64
+ super(
65
+ available: available,
66
+ remaining: remaining,
67
+ limit: limit,
68
+ reset_at: reset_at,
69
+ unit: unit,
70
+ checked_at: resolved_checked_at
71
+ )
72
+ freeze
73
+ end
74
+
75
+ # Whether this provider exposes any quota information at all.
76
+ #
77
+ # @return [Boolean]
78
+ def available? = available == true
79
+
80
+ # Whether the tracked quota is exhausted. Returns false when availability
81
+ # is unknown so callers can treat the status conservatively.
82
+ #
83
+ # @return [Boolean]
84
+ def exhausted?
85
+ return false unless available?
86
+ return false if remaining.nil?
87
+
88
+ remaining <= 0
89
+ end
90
+
91
+ # Human-friendly label for the +unit+ value, useful for logs and UIs.
92
+ #
93
+ # @return [String]
94
+ def unit_label
95
+ return "unknown" if unit.nil?
96
+
97
+ unit.to_s
98
+ end
99
+
100
+ # Serializable hash suitable for database persistence.
101
+ #
102
+ # +reset_at+ and +checked_at+ are serialized as ISO8601 strings in UTC so
103
+ # the hash round-trips through JSON without losing timezone information.
104
+ #
105
+ # @return [Hash{Symbol => Object}]
106
+ def to_h
107
+ {
108
+ available: available,
109
+ remaining: remaining,
110
+ limit: limit,
111
+ reset_at: serialize_time(reset_at),
112
+ unit: unit,
113
+ checked_at: serialize_time(checked_at)
114
+ }
115
+ end
116
+
117
+ # Build a QuotaStatus from a Hash produced by {#to_h}.
118
+ #
119
+ # @param hash [Hash] serialized QuotaStatus
120
+ # @return [QuotaStatus]
121
+ def self.from_h(hash)
122
+ return unavailable if hash.nil? || hash.empty?
123
+
124
+ new(
125
+ available: fetch_value(hash, :available),
126
+ remaining: fetch_value(hash, :remaining),
127
+ limit: fetch_value(hash, :limit),
128
+ reset_at: parse_time(fetch_value(hash, :reset_at)),
129
+ unit: normalize_unit(fetch_value(hash, :unit)),
130
+ checked_at: parse_time(fetch_value(hash, :checked_at))
131
+ )
132
+ end
133
+
134
+ def self.fetch_value(hash, key)
135
+ symbol_value = hash[key]
136
+ return symbol_value unless symbol_value.nil?
137
+
138
+ hash[key.to_s]
139
+ end
140
+ private_class_method :fetch_value
141
+
142
+ # Sentinel for "this provider does not expose a quota API." The default
143
+ # implementation of {Providers::Base#check_quota} returns this so callers
144
+ # can treat all providers uniformly.
145
+ #
146
+ # @return [QuotaStatus]
147
+ def self.unavailable
148
+ new(available: false)
149
+ end
150
+
151
+ def self.normalize_unit(value)
152
+ return nil if value.nil?
153
+
154
+ value.to_sym
155
+ end
156
+ private_class_method :normalize_unit
157
+
158
+ def self.parse_time(value)
159
+ return nil if value.nil?
160
+
161
+ Time.parse(value).utc
162
+ rescue ArgumentError
163
+ nil
164
+ end
165
+ private_class_method :parse_time
166
+
167
+ private
168
+
169
+ def serialize_time(value)
170
+ return nil unless value.is_a?(Time)
171
+
172
+ value.utc.iso8601
173
+ end
174
+ end
175
+ end