pwn 0.5.674 → 0.5.675

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a62ba09707c5e4256e02e501ace75b21fd3d29e0f3239b64e054743e8481deaa
4
- data.tar.gz: 8c3a604756f02a0f3e47fc7633cc6ed6b7362adb1613a82db8418668b2e3a1fa
3
+ metadata.gz: 2ed5f171854df678e76d477ad52fee2dd42e1687afc6e950281d2b3e5fe75ab9
4
+ data.tar.gz: 35712ab0300a98de2dfc52430278088561b84fcd15e4dd70a3c95cd90ccf4645
5
5
  SHA512:
6
- metadata.gz: cd13158fb37c453d6ed9eb20008ac7896cd9c31c1f4efdc60a18eaa00878ae1bd0a46cdb640ff4a49bd2edd9eeeb6e14a42f2c59c764916641b57a2a040e6477
7
- data.tar.gz: 39a6032209299723a26bb76160b980e4c51f3e7ba5e0bd06d7f1228bde1e5637ef9d91ed7f639071e42f6eeaaa8d9e2be688b678551dc6d14faddf1d8137f492
6
+ metadata.gz: 563ee1d396728ef91353cfc4e6d82da6b90662d03e55d1c027319203e47a8bd91d9c723a09cda3a6836dc9b1ed380bb3e0bb76d802dd23157ff7c92d52a3958b
7
+ data.tar.gz: 05e3c313c8262273f5799f24a9359b3cadc5448ccebfc5ae031a99a71865b548d24489208f4b7be6e0d4790485ebcc58d35a3ccdafbfddfecdb27eaddd531b9c
data/Gemfile CHANGED
@@ -49,7 +49,7 @@ gem 'jwt', '3.2.0'
49
49
  gem 'libusb', '0.8.0'
50
50
  gem 'luhn', '3.0.0'
51
51
  gem 'mail', '2.9.1'
52
- gem 'mcp', '1.1.0'
52
+ gem 'mcp', '1.2.0'
53
53
  gem 'meshtastic', '0.0.173'
54
54
  gem 'metasm', '1.0.6'
55
55
  gem 'mongo', '2.25.0'
data/bin/pwn CHANGED
@@ -51,9 +51,11 @@ begin
51
51
  # ------------------------------------------------------------------
52
52
  if opts[:setup] == true
53
53
  r = PWN::Setup.check
54
+ PWN::Setup.ensure_cron(restart: false)
54
55
  exit(r[:ok] ? 0 : 1)
55
56
  else
56
57
  PWN::Setup.deps(profile: opts[:setup])
58
+ PWN::Setup.ensure_cron(restart: false)
57
59
  end
58
60
  elsif opts[:ai]
59
61
  # ------------------------------------------------------------------
data/bin/pwn_setup CHANGED
@@ -59,6 +59,18 @@ pwn_driver.auto_opts_help = false
59
59
  pwn_driver.parse!
60
60
 
61
61
  begin
62
+ status = 0
63
+ # Seed default jobs + start/reuse the in-process scheduler so last_run
64
+ # actually advances. Do this BEFORE the doctor so `pwn setup` reports
65
+ # the worker as running. Skip list/terminal (no host scheduler needed)
66
+ # and dry-run (must not spawn).
67
+ unless opts[:list] || opts[:terminal] || opts[:dry_run]
68
+ PWN::Setup.ensure_cron(
69
+ restart: false,
70
+ dry_run: false
71
+ )
72
+ end
73
+
62
74
  if opts[:list]
63
75
  PWN::Setup.list_profiles
64
76
  elsif opts[:migrate]
@@ -66,7 +78,7 @@ begin
66
78
  fix: opts[:fix] || opts[:yes],
67
79
  dry_run: opts[:dry_run]
68
80
  )
69
- exit 1 unless r && r[:after] && r[:after][:incompatible].empty?
81
+ status = 1 unless r && r[:after] && r[:after][:incompatible].empty?
70
82
  elsif opts[:terminal]
71
83
  PWN::Setup.terminal(
72
84
  yes: opts[:yes],
@@ -83,8 +95,19 @@ begin
83
95
  # `pwn setup --check` → same
84
96
  # `pwn setup --check --profile net` → gate ONLY on the :net profile
85
97
  r = PWN::Setup.check(profile: opts[:profile])
86
- exit 1 unless r[:ok]
98
+ status = 1 unless r[:ok]
99
+ end
100
+
101
+ # Seed default jobs + start/reuse the in-process scheduler so
102
+ # last_run actually advances. Skip list/terminal (no host scheduler
103
+ # needed) and dry-run (must not spawn).
104
+ unless opts[:list] || opts[:terminal] || opts[:dry_run]
105
+ PWN::Setup.ensure_cron(
106
+ restart: false,
107
+ dry_run: false
108
+ )
87
109
  end
110
+ exit status
88
111
  rescue Interrupt
89
112
  puts "\naborted."
90
113
  exit 130
@@ -1063,6 +1063,16 @@ module PWN
1063
1063
  # 3.2 — local models cannot afford auto_introspect (judge+prm+critic+
1064
1064
  # sentinel+extro) on every success. Default :failure_only when local.
1065
1065
  private_class_method def self.should_auto_introspect?(opts = {})
1066
+ kind = (opts[:kind] || Thread.current[:pwn_request_kind]).to_s.to_sym
1067
+ intent = (opts[:intent] || Thread.current[:pwn_request_intent]).to_s.to_sym
1068
+ fails = opts[:turn_fails].is_a?(Hash) ? opts[:turn_fails].values.sum : 0
1069
+ # Cheap answers already returned user-visible text. The post-answer
1070
+ # critic + 12s ORM printed ERROR: Timed out reading data from server
1071
+ # after greetings / takes / questions.
1072
+ return false if %i[greeting howto recall].include?(intent)
1073
+ return false if kind == :statement
1074
+ return false if kind == :question && fails.zero?
1075
+
1066
1076
  return true unless opts[:local]
1067
1077
 
1068
1078
  policy = agent_flag(key: :local_introspect, default: :failure_only).to_s.to_sym
@@ -97,7 +97,8 @@ module PWN
97
97
  system_role_content: system_role_content,
98
98
  spinner: spinner,
99
99
  timeout: timeout,
100
- temp: temp
100
+ temp: temp,
101
+ quiet: opts[:quiet]
101
102
  )
102
103
  end
103
104
  ensure
@@ -132,6 +133,7 @@ module PWN
132
133
  chat_opts[:timeout] = opts[:timeout] unless opts[:timeout].nil?
133
134
  chat_opts[:temp] = opts[:temp] unless opts[:temp].nil?
134
135
  chat_opts[:model] = opts[:model] unless opts[:model].to_s.empty?
136
+ chat_opts[:quiet] = opts[:quiet] unless opts[:quiet].nil?
135
137
  mod.chat(chat_opts)
136
138
  end
137
139
 
@@ -1157,11 +1157,20 @@ module PWN
1157
1157
  spinner: false,
1158
1158
  model: judge_model,
1159
1159
  timeout: cheap_orm_timeout,
1160
- temp: CHEAP_ORM_TEMP
1160
+ temp: CHEAP_ORM_TEMP,
1161
+ quiet: true
1161
1162
  )
1162
1163
  text = extract_chat_text(resp: raw)
1163
1164
  return text unless text.strip.empty?
1164
- rescue StandardError
1165
+
1166
+ # Quiet ReadTimeout now returns nil instead of raising. That
1167
+ # empty response already spent the cheap-ORM budget — do not
1168
+ # immediately fire engine_chat_cheap (second 12s stall).
1169
+ return nil
1170
+ rescue StandardError => e
1171
+ # A 12s ReadTimeout already spent the cheap-ORM budget.
1172
+ return nil if timeout_error?(err: e)
1173
+
1165
1174
  nil
1166
1175
  end
1167
1176
  end
@@ -1191,7 +1200,8 @@ module PWN
1191
1200
  system_role_content: opts[:system_role_content],
1192
1201
  spinner: false,
1193
1202
  timeout: cheap_orm_timeout,
1194
- temp: CHEAP_ORM_TEMP
1203
+ temp: CHEAP_ORM_TEMP,
1204
+ quiet: true
1195
1205
  }
1196
1206
  model = judge_model
1197
1207
  chat_opts[:model] = model unless model.to_s.empty?
@@ -1200,6 +1210,20 @@ module PWN
1200
1210
  nil
1201
1211
  end
1202
1212
 
1213
+ private_class_method def self.timeout_error?(opts = {})
1214
+ err = opts[:err]
1215
+ return false if err.nil?
1216
+
1217
+ klass = err.class
1218
+ name = klass.name.to_s
1219
+ return true if name.include?('Timeout') || name.include?('ReadTimeout') || name.include?('OpenTimeout')
1220
+ return true if err.message.to_s.match?(/timed out reading|read timeout|open timeout/i)
1221
+
1222
+ false
1223
+ rescue StandardError
1224
+ false
1225
+ end
1226
+
1203
1227
  private_class_method def self.cheap_orm_timeout
1204
1228
  n = agent_flag(key: :reward_llm_timeout, default: CHEAP_ORM_TIMEOUT).to_i
1205
1229
  n = CHEAP_ORM_TIMEOUT if n < 2
@@ -566,7 +566,9 @@ module PWN
566
566
  request: user,
567
567
  system_role_content: system,
568
568
  suppress_pii_warning: true,
569
- spinner: false
569
+ spinner: false,
570
+ timeout: sidecar_timeout,
571
+ quiet: true
570
572
  )
571
573
  text = reflect_text(resp: resp)
572
574
  return text unless text.to_s.strip.empty?
@@ -846,7 +848,9 @@ module PWN
846
848
  request: user,
847
849
  system_role_content: system,
848
850
  suppress_pii_warning: true,
849
- spinner: false
851
+ spinner: false,
852
+ timeout: sidecar_timeout,
853
+ quiet: true
850
854
  )
851
855
  text = reflect_text(resp: resp)
852
856
  return text unless text.to_s.strip.empty?
@@ -896,7 +900,9 @@ module PWN
896
900
  r = mod.chat(
897
901
  request: opts[:request],
898
902
  system_role_content: opts[:system_role_content],
899
- spinner: false
903
+ spinner: false,
904
+ timeout: sidecar_timeout,
905
+ quiet: true
900
906
  )
901
907
  reflect_text(resp: r)
902
908
  rescue StandardError => e
@@ -911,6 +917,17 @@ module PWN
911
917
  :ollama
912
918
  end
913
919
 
920
+ # Kind / plan classification is a sidecar hop. Bound it so a hung
921
+ # model cannot stall the user's real answer, and stay quiet on timeout.
922
+ private_class_method def self.sidecar_timeout
923
+ n = (PWN::Env.dig(:ai, :agent, :sidecar_llm_timeout) if defined?(PWN::Env)).to_i
924
+ n = 20 if n < 5
925
+ n = 60 if n > 60
926
+ n
927
+ rescue StandardError
928
+ 20
929
+ end
930
+
914
931
  # Parse JSON array, fenced JSON, or numbered/bulleted plain text.
915
932
  #
916
933
  # Supported Method Parameters::
@@ -362,7 +362,7 @@ module PWN
362
362
  real_config_value?(value: oauth[:bearer_token]) ||
363
363
  real_config_value?(value: oauth[:refresh_token])
364
364
 
365
- if token.nil? && (oauth_opt_in || !real_config_value?(value: engine[:key]))
365
+ if token.nil? && (oauth_opt_in || !real_config_value?(value: engine[:key])) && !opts[:non_interactive]
366
366
  token = obtain_oauth_bearer_token(oauth)
367
367
  using_oauth = true
368
368
  end
@@ -372,9 +372,15 @@ module PWN
372
372
  using_oauth = false
373
373
  end
374
374
 
375
- token ||= PWN::Plugins::AuthenticationHelper.mask_password(
376
- prompt: 'Anthropic API Key (or run PWN::AI::Anthropic.obtain_oauth_bearer_token for Claude Pro/Max OAuth)'
377
- )
375
+ if token.nil?
376
+ return nil if opts[:non_interactive]
377
+
378
+ token = PWN::Plugins::AuthenticationHelper.mask_password(
379
+ prompt: 'Anthropic API Key (or run PWN::AI::Anthropic.obtain_oauth_bearer_token for Claude Pro/Max OAuth)'
380
+ )
381
+ end
382
+
383
+ return nil if token.nil?
378
384
 
379
385
  http_method = if opts[:http_method].nil?
380
386
  :get
@@ -488,6 +494,122 @@ module PWN
488
494
  raise e
489
495
  end
490
496
 
497
+ # Supported Method Parameters::
498
+ # usage = PWN::AI::Anthropic.get_plan_usage(
499
+ # timeout: 'optional - seconds (default 8)'
500
+ # )
501
+ #
502
+ # Admin Usage API (organizations/usage_report/messages) yields tokens
503
+ # used; organizations/rate_limits yields the configured ceiling so the
504
+ # PS1 can show a percent. Individual accounts without an Admin key
505
+ # return {available:false}. Never prompts for credentials.
506
+ public_class_method def self.get_plan_usage(opts = {})
507
+ timeout = opts[:timeout] || 8
508
+ return { available: false, engine: :anthropic } unless plan_usage_credentials?
509
+
510
+ now = Time.now.utc
511
+ start_of_month = Time.utc(now.year, now.month, 1)
512
+ report = parse_plan_usage_json(
513
+ raw: anthropic_rest_call(
514
+ rest_call: 'organizations/usage_report/messages',
515
+ params: {
516
+ starting_at: start_of_month.strftime('%Y-%m-%dT00:00:00Z'),
517
+ ending_at: now.strftime('%Y-%m-%dT%H:%M:%SZ'),
518
+ bucket_width: '1d'
519
+ },
520
+ timeout: timeout,
521
+ spinner: false,
522
+ non_interactive: true
523
+ )
524
+ )
525
+ used_tokens = sum_usage_tokens(report: report)
526
+
527
+ limits = parse_plan_usage_json(
528
+ raw: anthropic_rest_call(
529
+ rest_call: 'organizations/rate_limits',
530
+ timeout: timeout,
531
+ spinner: false,
532
+ non_interactive: true
533
+ )
534
+ )
535
+ limit_tokens = first_rate_limit(limits: limits)
536
+
537
+ normalized = PWN::AI.normalize_plan_usage(
538
+ used: used_tokens,
539
+ limit: limit_tokens,
540
+ source: 'organizations/usage_report',
541
+ engine: :anthropic
542
+ )
543
+ return normalized if normalized[:available]
544
+
545
+ { available: false, engine: :anthropic }
546
+ rescue StandardError
547
+ { available: false, engine: :anthropic }
548
+ end
549
+
550
+ private_class_method def self.plan_usage_credentials?
551
+ engine = PWN::Env.dig(:ai, :anthropic) || {}
552
+ oauth = engine[:oauth].is_a?(Hash) ? engine[:oauth] : {}
553
+ real_config_value?(value: engine[:key]) ||
554
+ real_config_value?(value: oauth[:bearer_token]) ||
555
+ real_config_value?(value: oauth[:refresh_token])
556
+ end
557
+
558
+ private_class_method def self.parse_plan_usage_json(opts = {})
559
+ raw = opts[:raw]
560
+ return nil if raw.nil?
561
+
562
+ parsed = JSON.parse(raw.to_s, symbolize_names: true)
563
+ parsed.is_a?(Hash) ? parsed : nil
564
+ rescue JSON::ParserError, TypeError
565
+ nil
566
+ end
567
+
568
+ private_class_method def self.sum_usage_tokens(opts = {})
569
+ report = opts[:report]
570
+ return nil unless report.is_a?(Hash)
571
+
572
+ buckets = report[:data] || report[:usage] || []
573
+ total = 0
574
+ Array(buckets).each do |bucket|
575
+ next unless bucket.is_a?(Hash)
576
+
577
+ results = bucket[:results] || bucket[:usage] || [bucket]
578
+ Array(results).each do |row|
579
+ next unless row.is_a?(Hash)
580
+
581
+ %i[
582
+ uncached_input_tokens
583
+ input_tokens
584
+ cache_read_input_tokens
585
+ cache_creation_input_tokens
586
+ output_tokens
587
+ ].each { |k| total += row[k].to_i }
588
+ end
589
+ end
590
+ total.positive? ? total : nil
591
+ end
592
+
593
+ private_class_method def self.first_rate_limit(opts = {})
594
+ limits = opts[:limits]
595
+ return nil unless limits.is_a?(Hash)
596
+
597
+ Array(limits[:data]).each do |group|
598
+ next unless group.is_a?(Hash)
599
+
600
+ Array(group[:limits]).each do |lim|
601
+ next unless lim.is_a?(Hash)
602
+
603
+ type = lim[:type].to_s
604
+ next unless type.match?(/tokens_per|input_tokens|output_tokens/)
605
+
606
+ v = lim[:value].to_f
607
+ return v if v.positive?
608
+ end
609
+ end
610
+ nil
611
+ end
612
+
491
613
  # ----------------------------------------------------------------------
492
614
  # Native tool-calling adapter for PWN::AI::Agent::Loop.
493
615
  #
@@ -832,6 +954,8 @@ module PWN
832
954
  puts "USAGE:
833
955
  models = #{self}.get_models
834
956
 
957
+ usage = #{self}.get_plan_usage
958
+
835
959
  # One-time Claude Pro/Max OAuth enrollment (PKCE paste flow):
836
960
  bearer = #{self}.obtain_oauth_bearer_token
837
961
  # Subsequent runs silently refresh via ai.anthropic.oauth.refresh_token
data/lib/pwn/ai/gemini.rb CHANGED
@@ -118,6 +118,19 @@ module PWN
118
118
  raise e
119
119
  end
120
120
 
121
+ # Supported Method Parameters::
122
+ # usage = PWN::AI::Gemini.get_plan_usage(
123
+ # timeout: 'optional - seconds (default 8)'
124
+ # )
125
+ #
126
+ # Gemini Generative Language has no subscription-usage endpoint that
127
+ # returns used-vs-limit for an API key. Cached-miss / unavailable so
128
+ # the PS1 shows ∞ unless the operator later injects used/limit.
129
+ public_class_method def self.get_plan_usage(opts = {})
130
+ _timeout = opts[:timeout] || 8
131
+ { available: false, engine: :gemini }
132
+ end
133
+
121
134
  # ----------------------------------------------------------------------
122
135
  # Native tool-calling adapter for PWN::AI::Agent::Loop.
123
136
  #
@@ -432,6 +445,8 @@ module PWN
432
445
  puts "USAGE:
433
446
  models = #{self}.get_models
434
447
 
448
+ usage = #{self}.get_plan_usage
449
+
435
450
  response = #{self}.chat(
436
451
  request: 'required - message to Gemini',
437
452
  model: 'optional - model to use for text generation (defaults to PWN::Env[:ai][:gemini][:model])',
data/lib/pwn/ai/grok.rb CHANGED
@@ -382,7 +382,7 @@ module PWN
382
382
  real_config_value?(value: oauth[:bearer_token]) ||
383
383
  real_config_value?(value: oauth[:refresh_token])
384
384
 
385
- if token.nil? && (oauth_opt_in || !real_config_value?(value: engine[:key]))
385
+ if token.nil? && (oauth_opt_in || !real_config_value?(value: engine[:key])) && !opts[:non_interactive]
386
386
  # Singular device-flow enrollment. Result is written back into the
387
387
  # live oauth hash so subsequent grok_rest_call invocations inside the
388
388
  # same pwn / pwn-ai process reuse it silently.
@@ -391,9 +391,15 @@ module PWN
391
391
 
392
392
  token = engine[:key] if token.nil? && real_config_value?(value: engine[:key])
393
393
 
394
- token ||= PWN::Plugins::AuthenticationHelper.mask_password(
395
- prompt: 'Grok API Key (or run PWN::AI::Grok.obtain_oauth_bearer_token for SuperGrok OAuth)'
396
- )
394
+ if token.nil?
395
+ return nil if opts[:non_interactive]
396
+
397
+ token = PWN::Plugins::AuthenticationHelper.mask_password(
398
+ prompt: 'Grok API Key (or run PWN::AI::Grok.obtain_oauth_bearer_token for SuperGrok OAuth)'
399
+ )
400
+ end
401
+
402
+ return nil if token.nil?
397
403
 
398
404
  http_method = opts[:http_method].nil? ? :get : opts[:http_method].to_s.scrub.to_sym
399
405
 
@@ -468,15 +474,24 @@ module PWN
468
474
 
469
475
  retry
470
476
  end
477
+ rescue RestClient::Exceptions::Timeout => e
478
+ # Sidecar hops (judge / kind / plan) pass quiet:true and a short
479
+ # timeout. Do not print ERROR: Timed out reading data from server:
480
+ # — ReadTimeout is ExceptionWithResponse with a nil body.
481
+ warn "[pwn-ai/grok] #{e.class}: #{e.message} (#{http_method.to_s.upcase} #{rest_call} timeout=#{timeout}s)" unless opts[:quiet]
482
+ nil
471
483
  rescue RestClient::ExceptionWithResponse => e
472
- puts "ERROR: #{e.message}: #{e.response}"
484
+ puts "ERROR: #{e.message}: #{e.response}" unless opts[:quiet]
485
+ "#{e.message}: #{e.response}" if opts[:quiet]
473
486
  rescue StandardError => e
474
487
  case e.message
475
488
  when '400 Bad Request', '404 Resource Not Found'
476
- "#{e.message}: #{e.response}"
489
+ nil
477
490
  else
478
- raise e
491
+ raise e unless opts[:quiet]
492
+
479
493
  end
494
+ "#{e.message}: #{e.response}"
480
495
  ensure
481
496
  spin.stop if spinner
482
497
  end
@@ -492,6 +507,162 @@ module PWN
492
507
  raise e
493
508
  end
494
509
 
510
+ # Supported Method Parameters::
511
+ # usage = PWN::AI::Grok.get_plan_usage(
512
+ # timeout: 'optional - seconds (default 8)'
513
+ # )
514
+ #
515
+ # Subscription / team usage for the PS1 percent suffix.
516
+ # GET /v1/api-key yields team_id; then billing preview /
517
+ # spending-limits / prepaid balance compute used vs limit.
518
+ # GET /v1/me is also tried (OAuth personal-team payload).
519
+ public_class_method def self.get_plan_usage(opts = {})
520
+ timeout = opts[:timeout] || 8
521
+ return { available: false, engine: :grok } unless plan_usage_credentials?
522
+
523
+ me = parse_plan_usage_json(
524
+ raw: grok_rest_call(rest_call: 'me', timeout: timeout, spinner: false, quiet: true, non_interactive: true)
525
+ )
526
+ if me.is_a?(Hash)
527
+ used = first_numeric(src: me, keys: %i[used usage spend spent used_usd usage_usd])
528
+ limit = first_numeric(src: me, keys: %i[limit quota allowance hard_limit spending_limit])
529
+ normalized = PWN::AI.normalize_plan_usage(
530
+ used: used,
531
+ limit: limit,
532
+ source: 'me',
533
+ engine: :grok
534
+ )
535
+ return normalized if normalized[:available]
536
+ end
537
+
538
+ key_info = parse_plan_usage_json(
539
+ raw: grok_rest_call(rest_call: 'api-key', timeout: timeout, spinner: false, quiet: true, non_interactive: true)
540
+ )
541
+ team_id = key_info.is_a?(Hash) ? key_info[:team_id] : nil
542
+ team_id ||= me.is_a?(Hash) ? (me[:team_id] || me.dig(:team, :id)) : nil
543
+
544
+ if team_id.to_s.strip != ''
545
+ preview = parse_plan_usage_json(
546
+ raw: grok_rest_call(
547
+ rest_call: "billing/teams/#{team_id}/postpaid/invoice/preview",
548
+ timeout: timeout,
549
+ spinner: false,
550
+ quiet: true,
551
+ non_interactive: true
552
+ )
553
+ )
554
+ limits = parse_plan_usage_json(
555
+ raw: grok_rest_call(
556
+ rest_call: "billing/teams/#{team_id}/postpaid/spending-limits",
557
+ timeout: timeout,
558
+ spinner: false,
559
+ quiet: true,
560
+ non_interactive: true
561
+ )
562
+ )
563
+ balance = parse_plan_usage_json(
564
+ raw: grok_rest_call(
565
+ rest_call: "billing/teams/#{team_id}/prepaid/balance",
566
+ timeout: timeout,
567
+ spinner: false,
568
+ quiet: true,
569
+ non_interactive: true
570
+ )
571
+ )
572
+
573
+ used = nil
574
+ limit = nil
575
+ if preview.is_a?(Hash)
576
+ core = preview[:coreInvoice] || preview[:core_invoice] || {}
577
+ used_cents = cents_val(src: core, key: :prepaidCreditsUsed) ||
578
+ cents_val(src: core, key: :prepaid_credits_used) ||
579
+ core[:amountAfterVat] ||
580
+ core[:amount_after_vat]
581
+ prepaid_cents = cents_val(src: core, key: :prepaidCredits) ||
582
+ cents_val(src: core, key: :prepaid_credits)
583
+ spend_limit_cents = preview[:effectiveSpendingLimit] || preview[:effective_spending_limit]
584
+ used = to_f_or_nil(value: used_cents)
585
+ limit = to_f_or_nil(value: spend_limit_cents)
586
+ limit = to_f_or_nil(value: prepaid_cents).abs if (limit.nil? || limit <= 0) && prepaid_cents
587
+ end
588
+ used = to_f_or_nil(value: cents_val(src: balance, key: :total)).to_f.abs if (used.nil? || used <= 0) && balance.is_a?(Hash)
589
+ if (limit.nil? || limit <= 0) && limits.is_a?(Hash)
590
+ sl = limits[:spendingLimits] || limits[:spending_limits] || {}
591
+ limit = to_f_or_nil(value: cents_val(src: sl, key: :effectiveSl)) ||
592
+ to_f_or_nil(value: cents_val(src: sl, key: :effectiveHardSl)) ||
593
+ to_f_or_nil(value: cents_val(src: sl, key: :softSl))
594
+ end
595
+
596
+ normalized = PWN::AI.normalize_plan_usage(
597
+ used: used,
598
+ limit: limit,
599
+ source: 'billing/teams',
600
+ engine: :grok
601
+ )
602
+ return normalized if normalized[:available]
603
+ end
604
+
605
+ { available: false, engine: :grok }
606
+ rescue StandardError
607
+ { available: false, engine: :grok }
608
+ end
609
+
610
+ private_class_method def self.plan_usage_credentials?
611
+ engine = PWN::Env.dig(:ai, :grok) || {}
612
+ oauth = engine[:oauth].is_a?(Hash) ? engine[:oauth] : {}
613
+ real_config_value?(value: engine[:key]) ||
614
+ real_config_value?(value: oauth[:bearer_token]) ||
615
+ real_config_value?(value: oauth[:refresh_token])
616
+ end
617
+
618
+ private_class_method def self.parse_plan_usage_json(opts = {})
619
+ raw = opts[:raw]
620
+ return nil if raw.nil?
621
+
622
+ parsed = JSON.parse(raw.to_s, symbolize_names: true)
623
+ parsed.is_a?(Hash) ? parsed : nil
624
+ rescue JSON::ParserError, TypeError
625
+ nil
626
+ end
627
+
628
+ private_class_method def self.cents_val(opts = {})
629
+ src = opts[:src]
630
+ key = opts[:key]
631
+ return nil unless src.is_a?(Hash)
632
+
633
+ v = src[key]
634
+ return v[:val] if v.is_a?(Hash) && v.key?(:val)
635
+ return v['val'] if v.is_a?(Hash) && v.key?('val')
636
+
637
+ v
638
+ end
639
+
640
+ private_class_method def self.to_f_or_nil(opts = {})
641
+ val = opts.is_a?(Hash) && opts.key?(:value) ? opts[:value] : opts
642
+ return nil if val.nil?
643
+ return val.to_f if val.respond_to?(:to_f)
644
+
645
+ nil
646
+ rescue StandardError
647
+ nil
648
+ end
649
+
650
+ private_class_method def self.first_numeric(opts = {})
651
+ src = opts[:src]
652
+ keys = Array(opts[:keys])
653
+ return nil unless src.is_a?(Hash)
654
+
655
+ keys.each do |k|
656
+ v = src[k]
657
+ next if v.nil?
658
+ next if v.is_a?(Hash)
659
+
660
+ f = v.to_f
661
+ return f if f.positive? || v.to_s.strip.match?(/\A-?\d/)
662
+ end
663
+ nil
664
+ end
665
+
495
666
  # Supported Method Parameters::
496
667
  # response = PWN::AI::Grok.chat_with_tools(
497
668
  # messages: 'required - full OpenAI-format messages array (system/user/assistant/tool)',
@@ -536,7 +707,8 @@ module PWN
536
707
  rest_call: 'chat/completions',
537
708
  http_body: http_body,
538
709
  timeout: opts[:timeout],
539
- spinner: opts[:spinner]
710
+ spinner: opts[:spinner],
711
+ quiet: opts[:quiet]
540
712
  )
541
713
  return nil if response.nil?
542
714
 
@@ -616,8 +788,10 @@ module PWN
616
788
  rest_call: rest_call,
617
789
  http_body: http_body,
618
790
  timeout: timeout,
619
- spinner: spinner
791
+ spinner: spinner,
792
+ quiet: opts[:quiet]
620
793
  )
794
+ return nil if response.nil? || response.to_s.strip.empty?
621
795
 
622
796
  json_resp = JSON.parse(response, symbolize_names: true)
623
797
  assistant_resp = json_resp[:choices].first[:message]
@@ -655,6 +829,8 @@ module PWN
655
829
  puts "USAGE:
656
830
  models = #{self}.get_models
657
831
 
832
+ usage = #{self}.get_plan_usage
833
+
658
834
  response = #{self}.chat(
659
835
  request: 'required - message to Grok',
660
836
  model: 'optional - model to use for text generation (defaults to PWN::Env[:ai][:grok][:model])',