agent-harness 0.31.0 → 0.33.1

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:)
@@ -25,7 +25,7 @@ module AgentHarness
25
25
  # for unsupported/unknown model lookups so downstream orchestrators
26
26
  # (smoke tests, tier fallback) have a stable, contract-backed choice
27
27
  # instead of relying on whichever model the CLI's default points at.
28
- DEFAULT_COMPATIBLE_MODEL_ID = "gpt-5-codex"
28
+ DEFAULT_COMPATIBLE_MODEL_ID = "gpt-5.2-codex"
29
29
 
30
30
  # Known CLI-gated model facts. Each entry may express a minimum Codex
31
31
  # CLI version required to drive the model, and/or auth-mode
@@ -40,7 +40,11 @@ module AgentHarness
40
40
  MODEL_COMPATIBILITY_FACTS = {
41
41
  "gpt-5.5" => {minimum_cli_version: "0.116.0"},
42
42
  "gpt-5.5-codex" => {minimum_cli_version: "0.116.0"},
43
- "gpt-5.5-pro" => {auth_modes: [:api_key].freeze}
43
+ "gpt-5.5-pro" => {auth_modes: [:api_key].freeze},
44
+ "gpt-5.6" => {auth_modes: [:api_key].freeze},
45
+ "gpt-5.6-luna" => {auth_modes: [:api_key].freeze},
46
+ "gpt-5.6-sol" => {auth_modes: [:api_key].freeze},
47
+ "gpt-5.6-terra" => {auth_modes: [:api_key].freeze}
44
48
  }.each_value(&:freeze).freeze
45
49
 
46
50
  # Models that the runner contract considers supported on every Codex
@@ -48,6 +52,7 @@ module AgentHarness
48
52
  # distinguish "unknown to the contract" from "explicitly supported."
49
53
  BASELINE_SUPPORTED_MODELS = %w[
50
54
  gpt-5
55
+ gpt-5.2-codex
51
56
  gpt-5-codex
52
57
  gpt-5-mini
53
58
  gpt-4o
@@ -661,6 +666,26 @@ module AgentHarness
661
666
 
662
667
  def cli_env_overrides = {"PAID_CODEX_SUBSCRIPTION_AUTH" => "1"}
663
668
 
669
+ # Proactively check quota for Codex's configured backend.
670
+ #
671
+ # Codex runners frequently route through OpenRouter by setting
672
+ # +OPENAI_BASE_URL=https://openrouter.ai/api/v1+. When the request env
673
+ # indicates OpenRouter, the check is delegated to
674
+ # {QuotaCheckers::OpenRouter}, which queries the +/credits+ endpoint.
675
+ # Otherwise OpenAI's quota API is not publicly exposed, so the check
676
+ # returns unavailable and callers fall back to {TokenUsageTracker}.
677
+ #
678
+ # @param env [Hash{String=>String}] request-scoped environment
679
+ # @param timeout [Numeric] time budget in seconds
680
+ # @return [AgentHarness::QuotaStatus]
681
+ def check_quota(env:, timeout: QuotaCheckers::OpenRouter::DEFAULT_TIMEOUT)
682
+ if QuotaCheckers::OpenRouter.routes_through_open_router?(env)
683
+ return QuotaCheckers::OpenRouter.check(env: env, timeout: timeout, logger: @logger)
684
+ end
685
+
686
+ QuotaStatus.unavailable
687
+ end
688
+
664
689
  def send_message(prompt:, **options)
665
690
  super
666
691
  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"