ask-llm-providers 0.13.1 → 0.13.2

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: eb3302be5ae8d8f06b099977ea396982deeb89b6d80838197eabbca2264ba3ea
4
- data.tar.gz: 543ded04571baad5263bfd721a5cb4b5fffc7733d4bcafaa29368563f4b48e92
3
+ metadata.gz: 3ce937df3ae9db671946c8d5c3d837498fd521066bb45996e595dac977ff9f24
4
+ data.tar.gz: 9702d0c8aea1b61aa24df7e8bb76c5cc19e576ce926076fb274bdb095691b666
5
5
  SHA512:
6
- metadata.gz: 43225bc5e3a0ce765d61cc8143f72539779c5cd7b459891d414045f7be2207978387cafa18d1568bae222968cf69fd4b8bf3af14053483c33b45c6460b53b7f4
7
- data.tar.gz: 429242e279e722113022e0b8bc6519e31076d0c379fabdb0a76a154e5a6b2fa388a0301b6ffeddbcd678f0f905362ce3770a1385a11124ac509b58326d4796fa
6
+ metadata.gz: 3ea28d9f251de830592fb92f7d78c71d9b7983dbfc69aa29617ef06df5d598edfa5d9e5a9c8f386a2f9b2d9be22cb76a9c54594c7710acfea17779aa7de9c16d
7
+ data.tar.gz: d67134217af9ddb29c91b8dd4c64cb1bbe68f2fd79da31bf0bac7a7b64abb681ca0f34e11e7327d587de13c531b722466075cb5e10f35c302acf174af4d3e17b
@@ -3,54 +3,169 @@
3
3
  require "json"
4
4
  require "date"
5
5
  require "fileutils"
6
+ require "rbconfig"
7
+ require "set"
6
8
 
7
9
  module Ask
8
10
  module LLM
9
11
  # Orchestrates model catalog loading from multiple sources:
10
12
  #
11
- # 1. Bundled JSON files in lib/ask/llm/models/*.json (shipped with the gem)
12
- # 2. ~/.ask-llm-providers/models.json (user-defined overrides)
13
- # 3. Provider API list_models() calls (on explicit refresh!)
13
+ # 1. Single bundled JSON file lib/ask/llm/models.json (shipped with the gem)
14
+ # legacy shard dir lib/ask/llm/models/*.json is still supported
15
+ # 2. OS-specific user cache (~/Library/Caches/ask-llm-providers/models.json
16
+ # on macOS, $XDG_CACHE_HOME/ask-llm-providers/models.json on Linux)
17
+ # populated by Catalog.refresh!
18
+ # 3. Explicit user overrides in ~/.ask-llm-providers/models.json
19
+ # 4. Provider API list_models() calls (on explicit refresh!)
14
20
  #
15
- # Loaded models are registered into Ask::ModelCatalog for use
16
- # by ask-agent, ask-mcp, and llm-proxy.
21
+ # Mirrors ruby_llm's store file bundle fallback and pi's
22
+ # manifest-validated data dir.
17
23
  #
18
- # Ask::LLM::Catalog.load! # load bundled + user config
19
- # Ask::LLM::Catalog.refresh! # also fetch from provider APIs
24
+ # Ask::LLM::Catalog.load! # bundled + cache + user config
25
+ # Ask::LLM::Catalog.ensure_loaded! # no-op if already loaded (lazy)
26
+ # Ask::LLM::Catalog.refresh! # also fetch from provider APIs
20
27
  #
21
28
  class Catalog
22
29
  class Error < StandardError; end
23
30
  class LoadError < Error; end
24
31
 
25
32
  USER_CONFIG_PATH = File.expand_path("~/.ask-llm-providers/models.json").freeze
33
+ BUNDLED_FILE = File.expand_path("models.json", __dir__).freeze
34
+ LEGACY_SHARD_PATTERN = File.expand_path("models/*.json", __dir__).freeze
35
+ SCHEMA_VERSION = 1
26
36
 
27
37
  class << self
28
- # Load bundled model definitions and user overrides into Ask::ModelCatalog.
38
+ # Load bundled + cached + user overrides into Ask::ModelCatalog.
29
39
  # Idempotent — subsequent calls clear and reload.
30
40
  def load!
31
41
  instance.clear
32
42
  instance.load_bundled
43
+ instance.load_cached
33
44
  instance.load_user_config
34
45
  instance.register_all
46
+ @loaded = true
35
47
  true
36
48
  end
37
49
 
50
+ # Ensure the catalog has been loaded at least once. Cheap no-op
51
+ # after the first load; lets callers be lazy like ruby_llm.
52
+ def ensure_loaded!
53
+ return true if @loaded
54
+ load!
55
+ end
38
56
 
57
+ def loaded?
58
+ !!@loaded
59
+ end
39
60
 
40
61
  # Like load! but also fetches model lists from configured providers'
41
62
  # list_models() APIs. Unknown models are added with minimal metadata.
63
+ # Persists the merged result to the OS cache.
42
64
  def refresh!
43
65
  load!
44
66
  instance.fetch_from_providers
45
67
  instance.register_all
68
+ instance.persist_cached
46
69
  true
47
70
  end
48
71
 
49
- private
72
+ # OS-aware cache path, like ruby_llm's ModelRegistry.cache_path.
73
+ def cache_path
74
+ return @cache_path if defined?(@cache_path) && @cache_path
50
75
 
51
- def symbolize_keys(hash)
52
- hash.transform_keys { |k| k.respond_to?(:to_sym) ? k.to_sym : k }
53
- end
76
+ env_path = ENV["ASK_LLM_PROVIDERS_CACHE"]
77
+ if env_path && !env_path.strip.empty?
78
+ return @cache_path = env_path
79
+ end
80
+
81
+ home = Dir.home
82
+ host_os = RbConfig::CONFIG["host_os"]
83
+
84
+ dir = if host_os.match?(/darwin/i)
85
+ File.join(home, "Library", "Caches", "ask-llm-providers")
86
+ elsif host_os.match?(/mswin|mingw|cygwin/i)
87
+ local = ENV.fetch("LOCALAPPDATA", nil)
88
+ local = File.join(home, "AppData", "Local") if local.to_s.empty?
89
+ File.join(local, "ask-llm-providers", "Cache")
90
+ else
91
+ xdg = ENV.fetch("XDG_CACHE_HOME", nil)
92
+ xdg = File.join(home, ".cache") if xdg.to_s.empty?
93
+ File.join(xdg, "ask-llm-providers")
94
+ end
95
+
96
+ @cache_path = File.join(dir, "models.json")
97
+ rescue ArgumentError
98
+ @cache_path = nil
99
+ end
100
+
101
+ def cache_etag_path
102
+ cp = cache_path
103
+ cp ? "#{cp}.etag" : nil
104
+ end
105
+
106
+ def manifest_path
107
+ cp = cache_path
108
+ cp ? File.join(File.dirname(cp), ".manifest.json") : nil
109
+ end
110
+
111
+ def read_cached_etag
112
+ path = cache_etag_path
113
+ return nil unless path && File.file?(path)
114
+ v = File.read(path).strip
115
+ v.empty? ? nil : v
116
+ rescue SystemCallError
117
+ nil
118
+ end
119
+
120
+ private
121
+
122
+ def write_cached_etag(etag)
123
+ path = cache_etag_path
124
+ return unless path
125
+ if etag && !etag.to_s.strip.empty?
126
+ FileUtils.mkdir_p(File.dirname(path))
127
+ atomic_write(path, "#{etag.strip}\n")
128
+ else
129
+ FileUtils.rm_f(path)
130
+ end
131
+ end
132
+
133
+ def write_manifest(data)
134
+ path = manifest_path
135
+ return unless path
136
+ require "digest"
137
+ sorted = data.sort_by { |e| [e["provider"].to_s, e["id"].to_s] }
138
+ hash = Digest::SHA256.hexdigest(JSON.generate(sorted))
139
+ manifest = {
140
+ "schemaVersion" => SCHEMA_VERSION,
141
+ "generatedAt" => Time.now.utc.iso8601,
142
+ "count" => data.size,
143
+ "sha256" => hash
144
+ }
145
+ FileUtils.mkdir_p(File.dirname(path))
146
+ atomic_write(path, JSON.pretty_generate(manifest) + "\n")
147
+ end
148
+
149
+ def atomic_write(destination, contents)
150
+ dir = File.dirname(destination)
151
+ FileUtils.mkdir_p(dir)
152
+ basename = File.basename(destination)
153
+ require "tempfile"
154
+ Tempfile.create([basename, ".tmp"], dir) do |tmp|
155
+ tmp.binmode
156
+ tmp.write(contents)
157
+ tmp.flush
158
+ tmp.fsync
159
+ mode = File.exist?(destination) ? (File.stat(destination).mode & 0o7777) : (0o666 & ~File.umask)
160
+ tmp.chmod(mode)
161
+ begin
162
+ File.rename(tmp.path, destination)
163
+ rescue Errno::EACCES, Errno::EEXIST
164
+ FileUtils.rm_f(destination)
165
+ File.rename(tmp.path, destination)
166
+ end
167
+ end
168
+ end
54
169
 
55
170
  def instance
56
171
  @instance ||= new
@@ -67,15 +182,51 @@ module Ask
67
182
  @model_keys.clear
68
183
  end
69
184
 
70
- # Load bundled model JSONs from the gem's lib/ask/llm/models/ directory.
185
+ # Load bundled model data. Prefers the single file; falls back to
186
+ # the legacy per-provider shard dir for local dev and for
187
+ # backwards-compatibility if the bundle is absent.
71
188
  def load_bundled
72
- pattern = File.expand_path("models/*.json", __dir__)
73
- Dir[pattern].sort.each do |path|
74
- raw = JSON.parse(File.read(path))
189
+ if File.exist?(self.class::BUNDLED_FILE)
190
+ raw = JSON.parse(File.read(self.class::BUNDLED_FILE))
75
191
  raw.each { |entry| add_entry(entry) }
192
+ else
193
+ Dir[LEGACY_SHARD_PATTERN].sort.each do |path|
194
+ raw = JSON.parse(File.read(path))
195
+ raw.each { |entry| add_entry(entry) }
196
+ end
76
197
  end
77
198
  end
78
199
 
200
+ # Load the writable OS cache (populated by refresh!). Valid array
201
+ # entries are merged over the bundle; invalid cache is ignored like
202
+ # ruby_llm's models_from_file.
203
+ def load_cached
204
+ path = self.class.cache_path
205
+ return unless path && File.file?(path)
206
+ raw = JSON.parse(File.read(path))
207
+ return unless raw.is_a?(Array)
208
+ raw.each { |entry| merge_or_add(entry) }
209
+ rescue JSON::ParserError => e
210
+ warn "Warning: Failed to parse cache #{path}: #{e.message}"
211
+ rescue SystemCallError => e
212
+ warn "Warning: Failed to read cache #{path}: #{e.message}"
213
+ end
214
+
215
+ # Persist the current entries to the OS cache (used after refresh!).
216
+ def persist_cached
217
+ path = self.class.cache_path
218
+ return unless path
219
+ return if @entries.empty?
220
+ FileUtils.mkdir_p(File.dirname(path))
221
+ data = @entries.sort_by { |e| [(e["provider"] || e[:provider]).to_s, (e["id"] || e[:id]).to_s] }
222
+ # Normalize to string-keyed hashes for stable JSON
223
+ normalized = data.map { |e| e.transform_keys(&:to_s) }
224
+ self.class.send(:atomic_write, path, JSON.pretty_generate(normalized) + "\n")
225
+ self.class.send(:write_manifest, normalized)
226
+ rescue SystemCallError => e
227
+ warn "Warning: Failed to persist cache #{path}: #{e.message}"
228
+ end
229
+
79
230
  # Load user-defined model overrides from ~/.ask-llm-providers/models.json.
80
231
  # Silently skipped if the file doesn't exist.
81
232
  def load_user_config