pwn 0.5.755 → 0.5.757

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: '088caeab6962a895018847c637332f41ca7033f69c7e8a547862d219c58b162c'
4
- data.tar.gz: b0562e89e6cd08079728c7d5f0b2f640b975f482ed028c1cbe6b27a363361172
3
+ metadata.gz: a9bcba2a6a58d7e3f7990a1dbbf0cc73a1234efe20bfd7dd0eae786ecc4c4c32
4
+ data.tar.gz: 252972a638225c5ddd3693839eb4fe7db2311529e3ceb2a32f69f0cc3056fd27
5
5
  SHA512:
6
- metadata.gz: 2730afce4cd70424a7fb927983bb417c1bb755846cd33113ee522f3b4d1a2e3a227251b7f3e5079b9bf3198224f8d36d5f31bed362acc37b6022fb0da03c417d
7
- data.tar.gz: 9ac89ddaeb41583bc8b0db2a98faf9696ae78aae7f8ecc2689f4f8519071e1c284d172245aeb586de4a33c4ad7a81e4dc7ee16a7b849c23f4429e4ee9456e8ca
6
+ metadata.gz: e581b4c0dd6a4b1e05beccfade96bc635f48bf47c63168209b4900e6fb68a9d8e0b2394b4f6458a5aa96a9373248a0f96adb9ae0dcdeea2210f10f908bae630c
7
+ data.tar.gz: c87a11936f1cf59e2d6373c2d941ccf33f5aa4edac9e91fa09e1c9b5b871bcf2def227c10a6659131bc4279e05c7b6d85e28eeb23ff701d3ebadb0a3359ba129
data/Gemfile CHANGED
@@ -18,7 +18,7 @@ gem 'aws-sdk', '3.3.0'
18
18
  gem 'barby', '0.7.0'
19
19
  gem 'base32', '0.3.4'
20
20
  gem 'bitcoin-ruby', '0.0.20'
21
- gem 'brakeman', '8.0.6'
21
+ gem 'brakeman', '8.1.0'
22
22
  gem 'bson', '5.2.0'
23
23
  gem 'bundler', '>=4.0.22'
24
24
  gem 'bundler-audit', '>=0.9.3'
@@ -117,7 +117,8 @@ changes only default values, not the configuration schema; no migration is neede
117
117
  | Shift+Enter, or trailing `\\` + Enter | Insert a newline. Terminals that cannot distinguish Shift+Enter need `tmux set -s extended-keys on` or the backslash fallback. |
118
118
  | Up/Down | In MISSION CONTROL, move the completion highlight while the Command menu is visible (wrapping at either end). Otherwise recall requests from `~/.pwn/pwn_history`, including prior runs; Down past the newest restores the draft and cursor. In SESSION, move one transcript row. Esc closes the menu to resume history recall. |
119
119
  | Home/End | In MISSION CONTROL, move the composer cursor to the start or end of the line. In SESSION, jump to the top of the transcript or back to the live bottom. |
120
- | Ctrl+X | Toggle the active pane between SESSION and MISSION CONTROL. The active title is reversed and marked `active`. Typing or Enter returns to MISSION CONTROL; Enter does not submit while SESSION is active. |
120
+ | Ctrl+T | Toggle the active pane between SESSION and MISSION CONTROL. Ctrl+X still toggles. The active title is reversed and marked `active`. Typing or Enter returns to MISSION CONTROL; Enter does not submit while SESSION is active. |
121
+ | Ctrl+S or Ctrl+G or `/swarm` | Open the draft-preserving swarm workspace; `/swarm dashboard` also opens it. Ctrl+S still saves inside the system-role editor. |
121
122
  | Ctrl+P / Ctrl+N | Alternative completion selection keys. In MISSION CONTROL they recall history when no menu is visible. In SESSION they scroll one row. Tab still accepts the highlight. |
122
123
  | Tab | Accept the highlighted as-you-type parameter |
123
124
  | Ctrl+O or `/status` | Inspect full status/settings, including overflowing header text; Esc or Ctrl+O closes |
@@ -125,7 +126,6 @@ changes only default values, not the configuration schema; no migration is neede
125
126
  | Ctrl+R | Incremental reverse search of request history from `~/.pwn/pwn_history`; repeat for an older match, Enter accepts into the draft without sending, Esc cancels and restores the draft/cursor |
126
127
  | `/clear` | Clear the session pane only; stored conversation and token totals remain |
127
128
  | `/verbose [on|off]` | Toggle compact or full tool/notice output |
128
- | Ctrl+G or `/swarm` | Open the draft-preserving swarm workspace; `/swarm dashboard` also opens it |
129
129
  | `/` + Enter or `/menu` | Open the slash menu |
130
130
  | PgUp/PgDn | Scroll the timeline; new output does not pull a scrolled viewport to the bottom |
131
131
  | `/model`, `/sessions resume ID` | Show model settings / change the next request's model or session while idle |
@@ -36,6 +36,7 @@ PWN::AI::Anthropic.refresh_oauth_bearer_token(opts)
36
36
  - `refresh_oauth_bearer_token`
37
37
  - `obtain_oauth_bearer_token`
38
38
  - `get_models`
39
+ - `get_model`
39
40
  - `chat_with_tools`
40
41
  - `chat`
41
42
  - `authors`
@@ -34,6 +34,7 @@ PWN::AI::Gemini.get_models(opts)
34
34
  ## Public methods
35
35
 
36
36
  - `get_models`
37
+ - `get_model`
37
38
  - `chat_with_tools`
38
39
  - `chat`
39
40
  - `authors`
@@ -36,6 +36,7 @@ PWN::AI::Grok.refresh_oauth_bearer_token(opts)
36
36
  - `refresh_oauth_bearer_token`
37
37
  - `obtain_oauth_bearer_token`
38
38
  - `get_models`
39
+ - `get_model`
39
40
  - `chat_with_tools`
40
41
  - `chat`
41
42
  - `authors`
@@ -0,0 +1,52 @@
1
+ ---
2
+ name: pwn-ai-modelcatalog
3
+ description: Drive PWN::AI::ModelCatalog from pwn_eval.
4
+ license: MIT
5
+ allowed-tools: [pwn, pwn_eval]
6
+ metadata:
7
+ bundled: true
8
+ generated: true
9
+ module: PWN::AI::ModelCatalog
10
+ source: pwn/ai/model_catalog.rb
11
+ ---
12
+
13
+ # PWN::AI::ModelCatalog
14
+
15
+ Shared catalog lookup for provider model records. Each provider still fetches its own payload; this only finds one named row without a second network call when the caller already has the list.
16
+
17
+ ## When to use
18
+
19
+ Call `PWN::AI::ModelCatalog` from `pwn_eval` when the task needs this module.
20
+ Do not reimplement it in shell.
21
+
22
+ ## Methodologies
23
+
24
+ Generated from `pwn/ai/model_catalog.rb`. Prefer the public class methods below.
25
+ Class methods take `(opts = {})` and read `opts`.
26
+
27
+ ## How to call
28
+
29
+ ```ruby
30
+ PWN::AI::ModelCatalog.help
31
+ PWN::AI::ModelCatalog.find_row(opts)
32
+ ```
33
+
34
+ ## Public methods
35
+
36
+ - `find_row`
37
+ - `catalog_rows`
38
+ - `model_row`
39
+ - `parse_row`
40
+ - `lookup_opts`
41
+ - `authors`
42
+ - `help`
43
+ - `model_row?`
44
+
45
+ ## Source
46
+
47
+ `pwn/ai/model_catalog.rb`
48
+
49
+ ## Verification
50
+
51
+ `PWN::AI::ModelCatalog.respond_to?(:find_row)` after the
52
+ module is loaded. Read the source for parameter names.
@@ -34,6 +34,7 @@ PWN::AI::Ollama.get_models(opts)
34
34
  ## Public methods
35
35
 
36
36
  - `get_models`
37
+ - `get_model`
37
38
  - `chat_with_tools`
38
39
  - `chat`
39
40
  - `authors`
@@ -36,6 +36,7 @@ PWN::AI::OpenAI.refresh_oauth_bearer_token(opts)
36
36
  - `refresh_oauth_bearer_token`
37
37
  - `obtain_oauth_bearer_token`
38
38
  - `get_models`
39
+ - `get_model`
39
40
  - `chat_with_tools`
40
41
  - `api_endpoint`
41
42
  - `chat`
@@ -34,6 +34,7 @@ PWN::AI::OpenWebUI.get_models(opts)
34
34
  ## Public methods
35
35
 
36
36
  - `get_models`
37
+ - `get_model`
37
38
  - `chat_with_tools`
38
39
  - `chat`
39
40
  - `authors`
@@ -12,7 +12,7 @@ metadata:
12
12
 
13
13
  # PWN::Plugins::REPL::AIConsoleUsage
14
14
 
15
- Provider-reported token totals and explicitly configured USD estimates.
15
+ Provider-reported token totals. USD comes from the active model's published prices, then from explicitly configured rates. Missing prices stay unavailable.
16
16
 
17
17
  ## When to use
18
18
 
@@ -510,6 +510,30 @@ module PWN
510
510
  raise e
511
511
  end
512
512
 
513
+ # Supported Method Parameters::
514
+ # model = PWN::AI::Anthropic.get_model(name: 'required - model id')
515
+ #
516
+ # The Models API lists id and capabilities. Pricing, when Anthropic
517
+ # includes it on that record, is passed through unchanged. A missing
518
+ # price field is not filled from a public price table.
519
+
520
+ public_class_method def self.get_model(opts = {})
521
+ name = opts[:name].to_s.strip
522
+ raise 'ERROR: Model name is required' if name.empty?
523
+
524
+ hop = PWN::AI::ModelCatalog.lookup_opts(opts)
525
+ row = PWN::AI::ModelCatalog.parse_row(raw: anthropic_rest_call(hop.merge(rest_call: "models/#{URI.encode_www_form_component(name)}")))
526
+ return row if PWN::AI::ModelCatalog.model_row?(row: row)
527
+ return nil if opts[:fallback] == false
528
+
529
+ listed = PWN::AI::ModelCatalog.parse_row(raw: anthropic_rest_call(hop.merge(rest_call: 'models')))
530
+ PWN::AI::ModelCatalog.find_row(models: listed.is_a?(Hash) ? listed[:data] : listed, name: name)
531
+ rescue StandardError => e
532
+ raise e if name.empty?
533
+
534
+ nil
535
+ end
536
+
513
537
  # ----------------------------------------------------------------------
514
538
  # Native tool-calling adapter for PWN::AI::Agent::Loop.
515
539
  #
@@ -578,7 +602,7 @@ module PWN
578
602
  input_schema: anthropic_input_schema(schema: fn[:parameters] || fn['parameters'])
579
603
  }
580
604
  end
581
- http_body[:tool_choice] = anth_tool_choice(choice: opts[:tool_choice]) if opts[:tool_choice]
605
+ http_body[:tool_choice] = anth_tool_choice(choice: opts[:tool_choice], model: model) if opts[:tool_choice]
582
606
  end
583
607
 
584
608
  response = anthropic_rest_call(
@@ -794,15 +818,28 @@ module PWN
794
818
 
795
819
  private_class_method def self.anth_tool_choice(opts = {})
796
820
  choice = opts[:choice]
797
- case choice
798
- when 'none', :none then { type: 'none' }
799
- when 'required', :required, 'any', :any then { type: 'any' }
800
- when Hash
801
- fn = choice[:function] || choice['function'] || choice
802
- { type: 'tool', name: fn[:name] || fn['name'] }
803
- else # 'auto', :auto, nil, anything else
804
- { type: 'auto' }
805
- end
821
+ mapped = case choice
822
+ when 'none', :none then { type: 'none' }
823
+ when 'required', :required, 'any', :any then { type: 'any' }
824
+ when Hash
825
+ fn = choice[:function] || choice['function'] || choice
826
+ { type: 'tool', name: fn[:name] || fn['name'] }
827
+ else
828
+ { type: 'auto' }
829
+ end
830
+ return { type: 'auto' } if !forced_tool_choice?(model: opts[:model]) && %w[any tool].include?(mapped[:type].to_s)
831
+
832
+ mapped
833
+ end
834
+
835
+ # Claude Mythos 5.1, Fable 5.1, Opus 5.5, and Sonnet 5.5 reject forced tool use.
836
+ # Mythos 5 still accepts type any/tool, so match the documented ids, not every Mythos model.
837
+ private_class_method def self.forced_tool_choice?(opts = {})
838
+ model = opts[:model].to_s
839
+ return false if model.match?(/claude-(?:mythos|fable)-5-1(?:-|\z)/i)
840
+ return false if model.match?(/claude-(?:opus|sonnet)-5-5(?:-|\z)/i)
841
+
842
+ true
806
843
  end
807
844
 
808
845
  # Supported Method Parameters::
@@ -958,6 +995,13 @@ module PWN
958
995
  # Run get models and return its result
959
996
  #{self}.get_models
960
997
 
998
+ # Return one model record. Pricing is included only when that record publishes it.
999
+ #{self}.get_model(
1000
+ name: 'required - model id',
1001
+ timeout: 'optional - seconds (default 15)',
1002
+ fallback: 'optional - false skips the full catalog when the direct route misses'
1003
+ )
1004
+
961
1005
  # Native tool-calling adapter for PWN::AI::Agent::Loop
962
1006
  #{self}.chat_with_tools(
963
1007
  assistant_message: 'optional - <same hash> }',
data/lib/pwn/ai/gemini.rb CHANGED
@@ -3,6 +3,7 @@
3
3
  require 'json'
4
4
  require 'rest-client'
5
5
  require 'securerandom'
6
+ require 'uri'
6
7
 
7
8
  module PWN
8
9
  module AI
@@ -134,6 +135,30 @@ module PWN
134
135
  raise e
135
136
  end
136
137
 
138
+ # Supported Method Parameters::
139
+ # model = PWN::AI::Gemini.get_model(name: 'required - model id or models/<id>')
140
+ #
141
+ # Gemini names models as models/<id> and usually publishes token limits,
142
+ # not USD rates. A pricing object on the record is kept; none is invented.
143
+
144
+ public_class_method def self.get_model(opts = {})
145
+ name = opts[:name].to_s.strip
146
+ raise 'ERROR: Model name is required' if name.empty?
147
+
148
+ hop = PWN::AI::ModelCatalog.lookup_opts(opts)
149
+ id = name.delete_prefix('models/')
150
+ row = PWN::AI::ModelCatalog.parse_row(raw: gemini_rest_call(hop.merge(rest_call: "models/#{URI.encode_www_form_component(id)}")))
151
+ return row if PWN::AI::ModelCatalog.model_row?(row: row)
152
+ return nil if opts[:fallback] == false
153
+
154
+ listed = PWN::AI::ModelCatalog.parse_row(raw: gemini_rest_call(hop.merge(rest_call: 'models')))
155
+ PWN::AI::ModelCatalog.find_row(models: listed.is_a?(Hash) ? listed[:models] : listed, name: name)
156
+ rescue StandardError => e
157
+ raise e if name.empty?
158
+
159
+ nil
160
+ end
161
+
137
162
  # ----------------------------------------------------------------------
138
163
  # Native tool-calling adapter for PWN::AI::Agent::Loop.
139
164
  #
@@ -460,6 +485,13 @@ module PWN
460
485
  # Run get models and return its result
461
486
  #{self}.get_models
462
487
 
488
+ # Return one model record. Gemini usually publishes token limits, not USD rates.
489
+ #{self}.get_model(
490
+ name: 'required - model id or models/<id>',
491
+ timeout: 'optional - seconds (default 15)',
492
+ fallback: 'optional - false skips the full catalog when the direct route misses'
493
+ )
494
+
463
495
  # Native tool-calling adapter for PWN::AI::Agent::Loop
464
496
  #{self}.chat_with_tools(
465
497
  assistant_message: 'optional - <same hash> }',
data/lib/pwn/ai/grok.rb CHANGED
@@ -525,6 +525,30 @@ module PWN
525
525
  raise e
526
526
  end
527
527
 
528
+ # Supported Method Parameters::
529
+ # model = PWN::AI::Grok.get_model(name: 'required - model id')
530
+ #
531
+ # xAI prices are integer USD cents per 100 million tokens on the model
532
+ # record itself (prompt/completion/cached). Zero means that component
533
+ # is unpublished, not free.
534
+
535
+ public_class_method def self.get_model(opts = {})
536
+ name = opts[:name].to_s.strip
537
+ raise 'ERROR: Model name is required' if name.empty?
538
+
539
+ hop = PWN::AI::ModelCatalog.lookup_opts(opts)
540
+ row = PWN::AI::ModelCatalog.parse_row(raw: grok_rest_call(hop.merge(rest_call: "models/#{URI.encode_www_form_component(name)}")))
541
+ return row if PWN::AI::ModelCatalog.model_row?(row: row)
542
+ return nil if opts[:fallback] == false
543
+
544
+ listed = PWN::AI::ModelCatalog.parse_row(raw: grok_rest_call(hop.merge(rest_call: 'models')))
545
+ PWN::AI::ModelCatalog.find_row(models: listed.is_a?(Hash) ? listed[:data] : listed, name: name)
546
+ rescue StandardError => e
547
+ raise e if name.empty?
548
+
549
+ nil
550
+ end
551
+
528
552
  # Supported Method Parameters::
529
553
  # response = PWN::AI::Grok.chat_with_tools(
530
554
  # messages: 'required - full OpenAI-format messages array (system/user/assistant/tool)',
@@ -741,6 +765,13 @@ module PWN
741
765
  # Run get models and return its result
742
766
  #{self}.get_models
743
767
 
768
+ # Return one model record, including published token prices when the provider sends them.
769
+ #{self}.get_model(
770
+ name: 'required - model id',
771
+ timeout: 'optional - seconds (default 15)',
772
+ fallback: 'optional - false skips the full catalog when the direct route misses'
773
+ )
774
+
744
775
  # Run chat with tools and return its result
745
776
  #{self}.chat_with_tools(
746
777
  messages: 'required - full OpenAI-format messages array (system/user/assistant/tool)',
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module PWN
6
+ module AI
7
+ # Shared catalog lookup for provider model records. Each provider still
8
+ # fetches its own payload; this only finds one named row without a second
9
+ # network call when the caller already has the list.
10
+ module ModelCatalog
11
+ # Return the catalog row whose id, slug, name, or model matches name.
12
+ public_class_method def self.find_row(opts = {})
13
+ name = opts[:name].to_s.strip
14
+ return nil if name.empty?
15
+
16
+ rows = catalog_rows(models: opts[:models])
17
+ wanted = name.delete_prefix('models/')
18
+ rows.find do |row|
19
+ next false unless row.is_a?(Hash)
20
+
21
+ ids = [
22
+ row[:id], row['id'], row[:slug], row['slug'], row[:name], row['name'],
23
+ row[:model], row['model'], row[:display_name], row['display_name']
24
+ ].compact.map { |value| value.to_s.delete_prefix('models/') }
25
+ aliases = row[:aliases] || row['aliases'] || []
26
+ ids.concat(Array(aliases).map(&:to_s))
27
+ ids.any? { |value| value == name || value == wanted }
28
+ end
29
+ end
30
+
31
+ # Flatten the list shapes get_models already returns.
32
+ public_class_method def self.catalog_rows(opts = {})
33
+ raw = opts[:models]
34
+ case raw
35
+ when Array then raw
36
+ when Hash then raw[:data] || raw[:models] || raw['data'] || raw['models'] || []
37
+ else []
38
+ end
39
+ end
40
+
41
+ # True when the hash is a model record rather than an error object.
42
+ public_class_method def self.model_row?(opts = {})
43
+ row = opts[:row]
44
+ return false unless row.is_a?(Hash)
45
+
46
+ %i[id slug name model].any? { |key| !(row[key] || row[key.to_s]).to_s.strip.empty? }
47
+ end
48
+
49
+ # Parse a REST body into a Hash or Array. Error strings are not catalogs.
50
+ public_class_method def self.parse_row(opts = {})
51
+ raw = opts[:raw]
52
+ return raw if raw.is_a?(Hash) || raw.is_a?(Array)
53
+ return nil if raw.nil?
54
+ return nil unless raw.is_a?(String) || raw.respond_to?(:to_str)
55
+
56
+ parsed = JSON.parse(raw.to_s, symbolize_names: true)
57
+ parsed.is_a?(Hash) || parsed.is_a?(Array) ? parsed : nil
58
+ rescue JSON::ParserError
59
+ nil
60
+ end
61
+
62
+ # Short, non-interactive hop so a price lookup cannot prompt or retry for minutes.
63
+ public_class_method def self.lookup_opts(opts = {})
64
+ timeout = opts[:timeout].to_i
65
+ timeout = 15 unless timeout.positive?
66
+ {
67
+ timeout: timeout,
68
+ non_interactive: opts.fetch(:non_interactive, true),
69
+ quiet: true,
70
+ spinner: false
71
+ }
72
+ end
73
+
74
+ public_class_method def self.authors
75
+ 'AUTHOR(S): 0day Inc. <support@0dayinc.com>'
76
+ end
77
+
78
+ public_class_method def self.help
79
+ puts "USAGE:
80
+ # Find one catalog row by id, slug, name, model, or alias.
81
+ #{self}.find_row(
82
+ models: 'required - get_models payload',
83
+ name: 'required - model id'
84
+ )
85
+
86
+ # Flatten a provider catalog into row hashes.
87
+ #{self}.catalog_rows(
88
+ models: 'required - get_models payload'
89
+ )
90
+
91
+ # True when the hash is a model record rather than an error object.
92
+ #{self}.model_row?(
93
+ row: 'required - catalog row hash to test'
94
+ )
95
+
96
+ # Parse one REST model body. Error strings are not catalogs.
97
+ #{self}.parse_row(
98
+ raw: 'required - response body'
99
+ )
100
+
101
+ # Short non-interactive options for a price lookup.
102
+ #{self}.lookup_opts(
103
+ timeout: 'optional - seconds (default 15, one attempt)',
104
+ non_interactive: 'optional - never prompt (default true)'
105
+ )
106
+
107
+ # Return module authors.
108
+ #{self}.authors
109
+ "
110
+ end
111
+ end
112
+ end
113
+ end
data/lib/pwn/ai/ollama.rb CHANGED
@@ -3,6 +3,7 @@
3
3
  require 'json'
4
4
  require 'base64'
5
5
  require 'securerandom'
6
+ require 'uri'
6
7
 
7
8
  module PWN
8
9
  module AI
@@ -405,6 +406,31 @@ module PWN
405
406
  raise e
406
407
  end
407
408
 
409
+ # Supported Method Parameters::
410
+ # model = PWN::AI::Ollama.get_model(name: 'required - tag name')
411
+ #
412
+ # Local tags have size and family, not token prices. get_model reads
413
+ # /api/show for that tag and returns it. Missing pricing stays missing.
414
+
415
+ public_class_method def self.get_model(opts = {})
416
+ name = opts[:name].to_s.strip
417
+ raise 'ERROR: Model name is required' if name.empty?
418
+
419
+ hop = PWN::AI::ModelCatalog.lookup_opts(opts)
420
+ row = PWN::AI::ModelCatalog.parse_row(
421
+ raw: ollama_rest_call(hop.merge(http_method: :post, rest_call: 'api/show', http_body: { model: name }))
422
+ )
423
+ return row if PWN::AI::ModelCatalog.model_row?(row: row)
424
+ return nil if opts[:fallback] == false
425
+
426
+ listed = PWN::AI::ModelCatalog.parse_row(raw: ollama_rest_call(hop.merge(rest_call: 'api/tags')))
427
+ PWN::AI::ModelCatalog.find_row(models: listed.is_a?(Hash) ? listed[:models] : listed, name: name)
428
+ rescue StandardError => e
429
+ raise e if name.empty?
430
+
431
+ nil
432
+ end
433
+
408
434
  # Coerce OpenAI-wire message history into Ollama-native shapes before
409
435
  # POST /api/chat (or Open WebUI /ollama/api/chat):
410
436
  # - function.arguments must be a Hash/Array object, not a JSON string
@@ -679,6 +705,13 @@ module PWN
679
705
  # Run get models and return its result
680
706
  #{self}.get_models
681
707
 
708
+ # Return one local tag via /api/show. Missing token prices stay missing.
709
+ #{self}.get_model(
710
+ name: 'required - tag name',
711
+ timeout: 'optional - seconds (default 15)',
712
+ fallback: 'optional - false skips /api/tags when /api/show misses'
713
+ )
714
+
682
715
  # Run chat with tools and return its result
683
716
  #{self}.chat_with_tools(
684
717
  messages: 'required - full OpenAI-format messages array (system/user/assistant/tool)',
@@ -593,6 +593,32 @@ module PWN
593
593
  raise e
594
594
  end
595
595
 
596
+ # Supported Method Parameters::
597
+ # model = PWN::AI::OpenAI.get_model(name: 'required - model id or slug')
598
+ #
599
+ # Platform catalogs use data[].id. ChatGPT/Codex catalogs use
600
+ # models[].slug and may nest pricing under a different key. get_model
601
+ # returns that one row; it does not invent a USD rate the catalog omitted.
602
+
603
+ public_class_method def self.get_model(opts = {})
604
+ name = opts[:name].to_s.strip
605
+ raise 'ERROR: Model name is required' if name.empty?
606
+
607
+ hop = PWN::AI::ModelCatalog.lookup_opts(opts)
608
+ row = PWN::AI::ModelCatalog.parse_row(raw: open_ai_rest_call(hop.merge(rest_call: "models/#{URI.encode_www_form_component(name)}")))
609
+ return row if PWN::AI::ModelCatalog.model_row?(row: row)
610
+ return nil if opts[:fallback] == false
611
+
612
+ listed = open_ai_rest_call(hop.merge(rest_call: 'models'))
613
+ return nil if listed.nil?
614
+
615
+ PWN::AI::ModelCatalog.find_row(models: catalog_models(raw: listed), name: name)
616
+ rescue StandardError => e
617
+ raise e if name.empty?
618
+
619
+ nil
620
+ end
621
+
596
622
  private_class_method def self.catalog_models(opts = {})
597
623
  raw = opts[:raw]
598
624
  raw = JSON.parse(raw, symbolize_names: true) if raw.is_a?(String) || raw.respond_to?(:to_str)
@@ -1517,6 +1543,13 @@ module PWN
1517
1543
  # Run get models and return its result
1518
1544
  #{self}.get_models
1519
1545
 
1546
+ # Return one model row by id or slug. A missing price is not invented.
1547
+ #{self}.get_model(
1548
+ name: 'required - model id or slug',
1549
+ timeout: 'optional - seconds (default 15)',
1550
+ fallback: 'optional - false skips the full catalog when the direct route misses'
1551
+ )
1552
+
1520
1553
  # Chat Completions vs Responses path for this model (tools may force Responses).
1521
1554
  #{self}.api_endpoint(
1522
1555
  model: 'required - OpenAI model id (e.g. gpt-6-astra or gpt-4o)',
@@ -3,6 +3,7 @@
3
3
  require 'json'
4
4
  require 'base64'
5
5
  require 'securerandom'
6
+ require 'uri'
6
7
 
7
8
  module PWN
8
9
  module AI
@@ -433,6 +434,35 @@ module PWN
433
434
  raise e
434
435
  end
435
436
 
437
+ # Supported Method Parameters::
438
+ # model = PWN::AI::OpenWebUI.get_model(name: 'required - model id')
439
+ #
440
+ # Open WebUI mixes OpenAI-compat rows and proxied Ollama tags. A direct
441
+ # model route is tried first; otherwise the already-fetched catalog row
442
+ # is returned. Local tags without a price stay unpriced.
443
+
444
+ public_class_method def self.get_model(opts = {})
445
+ name = opts[:name].to_s.strip
446
+ raise 'ERROR: Model name is required' if name.empty?
447
+
448
+ hop = PWN::AI::ModelCatalog.lookup_opts(opts)
449
+ encoded = URI.encode_www_form_component(name)
450
+ row = PWN::AI::ModelCatalog.parse_row(raw: openwebui_rest_call(hop.merge(rest_call: "api/v1/models/#{encoded}")))
451
+ return row if PWN::AI::ModelCatalog.model_row?(row: row)
452
+ return nil if opts[:fallback] == false
453
+
454
+ listed = PWN::AI::ModelCatalog.parse_row(raw: openwebui_rest_call(hop.merge(rest_call: 'api/v1/models')))
455
+ found = PWN::AI::ModelCatalog.find_row(models: listed.is_a?(Hash) ? (listed[:data] || listed[:models]) : listed, name: name)
456
+ return found if found
457
+
458
+ tags = PWN::AI::ModelCatalog.parse_row(raw: openwebui_rest_call(hop.merge(rest_call: 'ollama/api/tags')))
459
+ PWN::AI::ModelCatalog.find_row(models: tags.is_a?(Hash) ? tags[:models] : tags, name: name)
460
+ rescue StandardError => e
461
+ raise e if name.empty?
462
+
463
+ nil
464
+ end
465
+
436
466
  # Coerce OpenAI-wire message history into Ollama-native shapes before
437
467
  # POST /api/chat (or Open WebUI /ollama/api/chat):
438
468
  # - function.arguments must be a Hash/Array object, not a JSON string
@@ -705,6 +735,13 @@ module PWN
705
735
  # Run get models and return its result
706
736
  #{self}.get_models
707
737
 
738
+ # Return one catalog row. OpenAI-compat and proxied Ollama tags use different routes.
739
+ #{self}.get_model(
740
+ name: 'required - model id',
741
+ timeout: 'optional - seconds (default 15)',
742
+ fallback: 'optional - false skips the catalog list when the direct route misses'
743
+ )
744
+
708
745
  # Run chat with tools and return its result
709
746
  #{self}.chat_with_tools(
710
747
  messages: 'required - full OpenAI-format messages array (system/user/assistant/tool)',
data/lib/pwn/ai.rb CHANGED
@@ -13,6 +13,7 @@ module PWN
13
13
  autoload :Grok, 'pwn/ai/grok'
14
14
  autoload :HttpRetry, 'pwn/ai/http_retry'
15
15
  autoload :MCP, 'pwn/ai/mcp'
16
+ autoload :ModelCatalog, 'pwn/ai/model_catalog'
16
17
  autoload :Ollama, 'pwn/ai/ollama'
17
18
  autoload :OpenAI, 'pwn/ai/open_ai'
18
19
  autoload :OpenWebUI, 'pwn/ai/open_web_ui'