pwn 0.5.756 → 0.5.758

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: 7fac5f5a9f5b01e9c4ce830db72a7e454f1a2dce37b44e57f98e290c154cfbbf
4
- data.tar.gz: 1c508a671f0eb88f704bd38e58b7f3ad7154eca3e0e8d6b4d62852c7bf217a1b
3
+ metadata.gz: 004ad4f77955698492a48591db3dad3f54d141fb94ddb7952211e86dc4945526
4
+ data.tar.gz: 5c76e0a2f71fdc1afd13f8af828c2027cb8183f3e73612d81f220b864b6df636
5
5
  SHA512:
6
- metadata.gz: 311ad0755488c66ad2c92c10751c52c5b86b4b725aae976fa697b0f206fb3c88afc954371d8ec8628fcc9431449fe58b71948f4ec2e3ad73b189e88165bcf3b4
7
- data.tar.gz: e0055f29efafc74e550e84fd504debb71400acbfaf768eb1be82caa1d57cc1d79d49addfb78bb31608fdd0fd2716a4f68277b0d88272960e6b4dffb990b829f9
6
+ metadata.gz: ffe44c731211488e5d07938e29dc1c503307801de30d0c2effc55d139d0f00b0a40a7deeda375f05b581ea88e379b9b82f031b589236bd5a08edd590078b3511
7
+ data.tar.gz: 9c8275fd41e044aed262bcbe7fcf9fe00694b43227030ba1ce90d907c6e68b54efa53dacd3c3cb712890acc159005f5ecd4c9cf1caf065de8032cfc4123041de
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
  #
@@ -971,6 +995,13 @@ module PWN
971
995
  # Run get models and return its result
972
996
  #{self}.get_models
973
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
+
974
1005
  # Native tool-calling adapter for PWN::AI::Agent::Loop
975
1006
  #{self}.chat_with_tools(
976
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,11 +406,39 @@ 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
411
437
  # (string args → HTTP 400 "can't find closing '}' symbol")
412
438
  # - assistant content nil + tool_calls → "" (Open WebUI form validation)
439
+ # - system/developer rows after the first message are merged into one
440
+ # leading system message. Qwen-style templates raise HTTP 500
441
+ # "System message must be at the beginning" otherwise.
413
442
  private_class_method def self.parse_tool_arguments_object(opts = {})
414
443
  raw = opts[:arguments]
415
444
  case raw
@@ -432,7 +461,7 @@ module PWN
432
461
  end
433
462
 
434
463
  private_class_method def self.normalize_messages_for_ollama(opts = {})
435
- Array(opts[:messages]).filter_map do |m|
464
+ rows = Array(opts[:messages]).filter_map do |m|
436
465
  next unless m.is_a?(Hash)
437
466
 
438
467
  role = (m[:role] || m['role']).to_s
@@ -482,6 +511,71 @@ module PWN
482
511
 
483
512
  out
484
513
  end
514
+ lead_system(messages: rows, fold_into_user: opts[:fold_into_user])
515
+ end
516
+
517
+ # One system role is only safe when the server does not already prepend one.
518
+ # Models whose template raises "System message must be at the beginning"
519
+ # already carry a Modelfile SYSTEM prompt. Fold client system text into
520
+ # the first user message instead of sending a second system role.
521
+ private_class_method def self.lead_system(opts = {})
522
+ parts = []
523
+ rest = []
524
+ Array(opts[:messages]).each do |row|
525
+ next unless row.is_a?(Hash)
526
+
527
+ role = (row[:role] || row['role']).to_s
528
+ if %w[system developer].include?(role)
529
+ text = (row.key?(:content) ? row[:content] : row['content']).to_s
530
+ parts << text unless text.strip.empty?
531
+ else
532
+ rest << row
533
+ end
534
+ end
535
+ return rest if parts.empty?
536
+
537
+ text = parts.join("\n\n")
538
+ return [{ role: 'system', content: text }] + rest unless opts[:fold_into_user]
539
+
540
+ user = rest.find { |row| (row[:role] || row['role']).to_s == 'user' }
541
+ if user
542
+ body = (user.key?(:content) ? user[:content] : user['content']).to_s
543
+ user[:content] = "#{text}\n\n#{body}"
544
+ user.delete('content')
545
+ rest
546
+ else
547
+ [{ role: 'user', content: text }] + rest
548
+ end
549
+ end
550
+
551
+ private_class_method def self.strict_system_template?(opts = {})
552
+ name = opts[:model].to_s.strip
553
+ return false if name.empty?
554
+
555
+ @strict_system_cache ||= {}
556
+ return @strict_system_cache[name] if @strict_system_cache.key?(name)
557
+
558
+ @strict_system_cache[name] = template_rejects_later_system?(model: name)
559
+ rescue StandardError
560
+ false
561
+ end
562
+
563
+ private_class_method def self.template_rejects_later_system?(opts = {})
564
+ name = opts[:model].to_s
565
+ names = [name]
566
+ names << "#{name}:latest" unless name.include?(':')
567
+ names.each do |candidate|
568
+ raw = ollama_rest_call(
569
+ http_method: :post, rest_call: 'api/show',
570
+ http_body: { model: candidate }, timeout: 8, quiet: true, non_interactive: true
571
+ )
572
+ template = PWN::AI::ModelCatalog.parse_row(raw: raw)
573
+ text = template.is_a?(Hash) ? (template[:template] || template['template']).to_s : ''
574
+ return true if text.include?('System message must be at the beginning')
575
+ end
576
+ false
577
+ rescue StandardError
578
+ false
485
579
  end
486
580
 
487
581
  # Supported Method Parameters::
@@ -500,13 +594,16 @@ module PWN
500
594
  # NDJSON chunks back into a single response.
501
595
 
502
596
  public_class_method def self.chat_with_tools(opts = {})
503
- engine = PWN::Env[:ai][:ollama]
504
- messages = normalize_messages_for_ollama(messages: opts[:messages])
505
- raise 'ERROR: messages array is required' if messages.nil? || messages.empty?
506
-
597
+ engine = PWN::Env[:ai][:ollama]
507
598
  model = opts[:model] ||= engine[:model]
508
599
  raise 'ERROR: Model is required. Call #get_models method for details' if model.nil?
509
600
 
601
+ messages = normalize_messages_for_ollama(
602
+ messages: opts[:messages],
603
+ fold_into_user: strict_system_template?(model: model)
604
+ )
605
+ raise 'ERROR: messages array is required' if messages.nil? || messages.empty?
606
+
510
607
  temp = opts[:temp].to_f
511
608
  temp = engine[:temp].to_f.nonzero? || 1 if temp.zero?
512
609
 
@@ -630,6 +727,10 @@ module PWN
630
727
  end
631
728
 
632
729
  http_body[:messages].push(user_role)
730
+ http_body[:messages] = lead_system(
731
+ messages: http_body[:messages],
732
+ fold_into_user: strict_system_template?(model: model)
733
+ )
633
734
 
634
735
  timeout = opts[:timeout]
635
736
  spinner = opts[:spinner]
@@ -679,6 +780,13 @@ module PWN
679
780
  # Run get models and return its result
680
781
  #{self}.get_models
681
782
 
783
+ # Return one local tag via /api/show. Missing token prices stay missing.
784
+ #{self}.get_model(
785
+ name: 'required - tag name',
786
+ timeout: 'optional - seconds (default 15)',
787
+ fallback: 'optional - false skips /api/tags when /api/show misses'
788
+ )
789
+
682
790
  # Run chat with tools and return its result
683
791
  #{self}.chat_with_tools(
684
792
  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)',