ask-llm-providers 0.2.2 → 0.3.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 858bdb99927e65448a7c3c584f2b0bed51bbe1a12d9ee43e0c2e4a97a34ee344
4
- data.tar.gz: 132b02262647f35a04232385e4148d4754b30a893da4d3015eef52a8cee72caf
3
+ metadata.gz: d9356b3e21bb6e8e3c69f531eda38a4ae965ac5c19f20e655bd0ebe3c927f729
4
+ data.tar.gz: 97d0c10a4937f3d8b6102392328d48b2f5bbf2ad73621fb8139759a892ff0df7
5
5
  SHA512:
6
- metadata.gz: 8999b96cbe13c709393c4208be9e49513059f0e239da1755e4061be85d3084ae4bcd52418cfcbac6add0f2ded90ad58e43833f2e49488893151f4aea989893f6
7
- data.tar.gz: 2b5dac9e60b02a4c28e8f406159064454b068c2fe45c215820629bd0e02f5d0da8f81d090d142f1ee0d0a91d68a37b267853327617cf34e8219bc0ec780ae39c
6
+ metadata.gz: 138d21391c23578668a9fecec6160a4403e8c01019a2c9795af0ab5f62e380958fe0f9735ef994b7f4a8b9115f060f8a83b724b58a558483465c14a81cb99b6c
7
+ data.tar.gz: e8e0e7bc5bd760723b01d9e246b443631f87e0a29fc473f2ac673e3115fe772d5732d2ae90f8e4903296e1d5b70c36ccc17de9d4900d4d66c658f5e5523b4add
data/CHANGELOG.md CHANGED
@@ -1,4 +1,26 @@
1
- ## [0.2.2] - 2026-06-25
1
+ ## [0.3.1] 2026-07-14
2
+
3
+ ### Removed
4
+ - `Ask::ModelCatalog::PROVIDER_PREFERENCE` removed from ask-core. `find(model_id)` now returns all matching models — no more provider preference disambiguation at the catalog level.
5
+
6
+ ## [0.3.0] — 2026-07-14
7
+
8
+ ### Added
9
+ - **Model catalog system** — `Ask::LLM::Catalog` loads model definitions from per-provider JSON files (`lib/ask/llm/models/*.json`), user overrides (`~/.ask-llm-providers/models.json`), and provider API `list_models()` on explicit refresh.
10
+ - **Per-provider model JSONs** — 12 JSON files (openai, anthropic, gemini, deepseek, opencode, opencode_go, mimo, openrouter, ollama, mistral, bedrock, cloudflare) with id, name, provider, capabilities, context window, modalities, and pricing.
11
+ - **Model aliases** — `Ask::LLM::Aliases` resolves short names (e.g. `claude-sonnet-4` → `claude-sonnet-4-6`). Alias entries are automatically registered into `Ask::ModelCatalog` so `ModelCatalog.find` works with alias names.
12
+ - **User config support** — `~/.ask-llm-providers/models.json` overrides bundled model fields or adds custom models.
13
+ - **`opencode.json` includes `deepseek-v4-flash`** — matches the default model configuration.
14
+
15
+ ### Changed
16
+ - Removed hardcoded `Ask::LLM::Models::OPENAI_MODELS` constants — replaced with catalog-driven model loading.
17
+ - `Ask::LLM::Aliases.resolve` now aliases `deepseek-v4` → `deepseek-v4-flash`, `gpt-4o-latest` → `gpt-4o`, `gpt-4.1-latest` → `gpt-4.1`.
18
+
19
+ ### Fixed
20
+ - Model entries now include `"provider"` field in JSON files (was missing from generated data).
21
+ - User config merges properly override bundled values (was keeping old values on conflict).
22
+
23
+ ## [0.2.2] — 2026-06-25
2
24
 
3
25
  ### Changed
4
26
  - Extended per-provider tests (Anthropic 18t, Google 14t, DeepSeek 16t, Mistral, Ollama, Cloudflare, Bedrock). Fixed providers_test.rb syntax error. RuboCop, overcommit, gemspec test, SimpleCov, CI.
@@ -0,0 +1,11 @@
1
+ {
2
+ "claude-sonnet-4": "claude-sonnet-4-6",
3
+ "claude-sonnet-4-5": "claude-sonnet-4-6",
4
+ "claude-opus-4": "claude-opus-4-7",
5
+ "claude-opus-4-5": "claude-opus-4-7",
6
+ "claude-haiku-4": "claude-haiku-4-5",
7
+ "gemini-3": "gemini-3-flash",
8
+ "deepseek-v4": "deepseek-v4-flash",
9
+ "gpt-4o-latest": "gpt-4o",
10
+ "gpt-4.1-latest": "gpt-4.1"
11
+ }
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Ask
6
+ module LLM
7
+ # Resolves model name aliases to canonical model IDs.
8
+ #
9
+ # Aliases are defined in aliases.json and allow users to refer
10
+ # to models by shorter or more familiar names. Resolution is
11
+ # provider-scoped — you can alias "claude-sonnet-4" to different
12
+ # canonical IDs depending on which provider serves it.
13
+ #
14
+ # Ask::LLM::Aliases.resolve("claude-sonnet-4")
15
+ # # => "claude-sonnet-4-6"
16
+ #
17
+ # Aliases are loaded lazily from the bundled JSON file.
18
+ module Aliases
19
+ ALIASES_PATH = File.expand_path("aliases.json", __dir__)
20
+
21
+ class << self
22
+ # Resolve an alias to a canonical model ID.
23
+ # Returns the input name unchanged if no alias is registered.
24
+ def resolve(name)
25
+ load_aliases unless @aliases
26
+ @aliases[name.to_s] || name.to_s
27
+ end
28
+
29
+ # Register a custom alias at runtime.
30
+ def register(short_name, canonical_id)
31
+ load_aliases unless @aliases
32
+ @aliases[short_name.to_s] = canonical_id.to_s
33
+ end
34
+
35
+ # Reload aliases from the bundled JSON file.
36
+ def reload!
37
+ @aliases = nil
38
+ load_aliases
39
+ end
40
+
41
+ # All registered aliases (for introspection).
42
+ def all
43
+ load_aliases unless @aliases
44
+ @aliases.dup
45
+ end
46
+
47
+ private
48
+
49
+ def load_aliases
50
+ @aliases = {}
51
+ path = ALIASES_PATH
52
+ return unless File.exist?(path)
53
+
54
+ raw = JSON.parse(File.read(path))
55
+ raw.each { |k, v| @aliases[k.to_s] = v.to_s }
56
+ rescue JSON::ParserError
57
+ # Invalid aliases file — log and use empty map
58
+ end
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,195 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "date"
5
+ require "fileutils"
6
+
7
+ module Ask
8
+ module LLM
9
+ # Orchestrates model catalog loading from multiple sources:
10
+ #
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!)
14
+ #
15
+ # Loaded models are registered into Ask::ModelCatalog for use
16
+ # by ask-agent, ask-mcp, and llm-proxy.
17
+ #
18
+ # Ask::LLM::Catalog.load! # load bundled + user config
19
+ # Ask::LLM::Catalog.refresh! # also fetch from provider APIs
20
+ #
21
+ class Catalog
22
+ class Error < StandardError; end
23
+ class LoadError < Error; end
24
+
25
+ USER_CONFIG_PATH = File.expand_path("~/.ask-llm-providers/models.json").freeze
26
+
27
+ class << self
28
+ # Load bundled model definitions and user overrides into Ask::ModelCatalog.
29
+ # Idempotent — subsequent calls clear and reload.
30
+ def load!
31
+ instance.clear
32
+ instance.load_bundled
33
+ instance.load_user_config
34
+ instance.register_all
35
+ true
36
+ end
37
+
38
+ # Like load! but also fetches model lists from configured providers'
39
+ # list_models() APIs. Unknown models are added with minimal metadata.
40
+ def refresh!
41
+ load!
42
+ instance.fetch_from_providers
43
+ instance.register_all
44
+ true
45
+ end
46
+
47
+ private
48
+
49
+ def symbolize_keys(hash)
50
+ hash.transform_keys { |k| k.respond_to?(:to_sym) ? k.to_sym : k }
51
+ end
52
+
53
+ def instance
54
+ @instance ||= new
55
+ end
56
+ end
57
+
58
+ def initialize
59
+ @entries = []
60
+ @model_keys = Set.new
61
+ end
62
+
63
+ def clear
64
+ @entries.clear
65
+ @model_keys.clear
66
+ end
67
+
68
+ # Load bundled model JSONs from the gem's lib/ask/llm/models/ directory.
69
+ def load_bundled
70
+ pattern = File.expand_path("models/*.json", __dir__)
71
+ Dir[pattern].sort.each do |path|
72
+ raw = JSON.parse(File.read(path))
73
+ raw.each { |entry| add_entry(entry) }
74
+ end
75
+ end
76
+
77
+ # Load user-defined model overrides from ~/.ask-llm-providers/models.json.
78
+ # Silently skipped if the file doesn't exist.
79
+ def load_user_config
80
+ path = USER_CONFIG_PATH
81
+ return unless File.exist?(path)
82
+
83
+ raw = JSON.parse(File.read(path))
84
+ unless raw.is_a?(Array)
85
+ warn "Warning: #{path} should be a JSON array of model entries, got #{raw.class}"
86
+ return
87
+ end
88
+
89
+ raw.each { |entry| merge_or_add(entry) }
90
+ rescue JSON::ParserError => e
91
+ warn "Warning: Failed to parse #{path}: #{e.message}"
92
+ end
93
+
94
+ # Fetch model lists from all configured providers via their list_models() API.
95
+ # Adds unknown models with minimal metadata (no capability guessing).
96
+ def fetch_from_providers
97
+ Ask::Provider.providers.each do |slug, provider_class|
98
+ next unless provider_class.configured?(nil)
99
+
100
+ begin
101
+ provider = provider_class.new
102
+ models = provider.list_models
103
+ models.each do |m|
104
+ add_entry(m) unless @model_keys.include?([m[:id], slug.to_s])
105
+ end
106
+ rescue StandardError => e
107
+ warn "Warning: Failed to fetch models from #{slug}: #{e.message}"
108
+ end
109
+ end
110
+ end
111
+
112
+ # Register all accumulated entries into Ask::ModelCatalog.
113
+ # Also registers alias entries so models can be found by alias name.
114
+ def register_all
115
+ @entries.each do |entry|
116
+ info = build_model_info(entry)
117
+ Ask::ModelCatalog.instance.register(info)
118
+ end
119
+
120
+ register_alias_entries
121
+ end
122
+
123
+ private
124
+
125
+ # For each alias (short_name → canonical_id), register a duplicate
126
+ # ModelInfo for every canonical entry whose id matches.
127
+ def register_alias_entries
128
+ Ask::LLM::Aliases.all.each do |short_name, canonical_id|
129
+ next if short_name == canonical_id
130
+
131
+ @entries.each do |entry|
132
+ next unless entry["id"] == canonical_id || entry[:id] == canonical_id
133
+
134
+ alias_entry = entry.merge("id" => short_name)
135
+ info = build_model_info(alias_entry)
136
+ Ask::ModelCatalog.instance.register(info)
137
+ end
138
+ end
139
+ end
140
+
141
+ def symbolize_keys(hash)
142
+ hash.transform_keys { |k| k.respond_to?(:to_sym) ? k.to_sym : k }
143
+ end
144
+
145
+ def add_entry(entry)
146
+ key = entry_key(entry)
147
+ return if @model_keys.include?(key)
148
+
149
+ @entries << entry
150
+ @model_keys << key
151
+ end
152
+
153
+ def merge_or_add(entry)
154
+ key = entry_key(entry)
155
+ existing = @entries.find { |e| entry_key(e) == key }
156
+
157
+ if existing
158
+ existing.merge!(entry)
159
+ else
160
+ @entries << entry
161
+ @model_keys << key
162
+ end
163
+ end
164
+
165
+ def entry_key(entry)
166
+ id = entry["id"] || entry[:id]
167
+ provider = entry["provider"] || entry[:provider]
168
+ [id, provider.to_s]
169
+ end
170
+
171
+ def build_model_info(entry)
172
+ e = entry.transform_keys(&:to_sym)
173
+ modalities = e[:modalities]
174
+ modalities = symbolize_keys(modalities) if modalities
175
+
176
+ Ask::ModelInfo.new(
177
+ id: e[:id],
178
+ name: e[:name] || e[:id],
179
+ provider: e[:provider],
180
+ family: e[:family],
181
+ capabilities: Array(e[:capabilities]),
182
+ context_window: e[:context_window],
183
+ max_output_tokens: e[:max_output_tokens],
184
+ modalities: modalities || { input: %w[text], output: %w[text] },
185
+ pricing: e[:pricing] || {},
186
+ knowledge_cutoff: e[:knowledge_cutoff] ? Date.parse(e[:knowledge_cutoff].to_s) : nil,
187
+ created_at: e[:created_at] ? Date.parse(e[:created_at].to_s) : nil,
188
+ metadata: (e[:metadata] || {}).merge(source: e[:metadata]&.dig("source") || "bundled")
189
+ )
190
+ rescue Date::Error
191
+ Ask::ModelInfo.new(id: e[:id], provider: e[:provider])
192
+ end
193
+ end
194
+ end
195
+ end
@@ -0,0 +1,188 @@
1
+ [
2
+ {
3
+ "id": "claude-sonnet-4-5",
4
+ "name": "Claude Sonnet 4.5",
5
+ "family": "claude_sonnet",
6
+ "context_window": 200000,
7
+ "max_output_tokens": 64000,
8
+ "capabilities": [
9
+ "function_calling",
10
+ "streaming",
11
+ "structured_output",
12
+ "vision",
13
+ "reasoning",
14
+ "prompt_caching",
15
+ "tool_choice",
16
+ "parallel_tool_calls"
17
+ ],
18
+ "modalities": {
19
+ "input": [
20
+ "text",
21
+ "image",
22
+ "pdf"
23
+ ],
24
+ "output": [
25
+ "text"
26
+ ]
27
+ },
28
+ "provider": "anthropic"
29
+ },
30
+ {
31
+ "id": "claude-sonnet-4",
32
+ "name": "Claude Sonnet 4",
33
+ "family": "claude_sonnet",
34
+ "context_window": 200000,
35
+ "max_output_tokens": 64000,
36
+ "capabilities": [
37
+ "function_calling",
38
+ "streaming",
39
+ "structured_output",
40
+ "vision",
41
+ "reasoning",
42
+ "prompt_caching",
43
+ "tool_choice",
44
+ "parallel_tool_calls"
45
+ ],
46
+ "modalities": {
47
+ "input": [
48
+ "text",
49
+ "image",
50
+ "pdf"
51
+ ],
52
+ "output": [
53
+ "text"
54
+ ]
55
+ },
56
+ "provider": "anthropic"
57
+ },
58
+ {
59
+ "id": "claude-4-opus",
60
+ "name": "Claude Opus 4",
61
+ "family": "claude_opus",
62
+ "context_window": 200000,
63
+ "max_output_tokens": 64000,
64
+ "capabilities": [
65
+ "function_calling",
66
+ "streaming",
67
+ "structured_output",
68
+ "vision",
69
+ "reasoning",
70
+ "prompt_caching",
71
+ "tool_choice",
72
+ "parallel_tool_calls"
73
+ ],
74
+ "modalities": {
75
+ "input": [
76
+ "text",
77
+ "image",
78
+ "pdf"
79
+ ],
80
+ "output": [
81
+ "text"
82
+ ]
83
+ },
84
+ "provider": "anthropic"
85
+ },
86
+ {
87
+ "id": "claude-haiku-4-5",
88
+ "name": "Claude Haiku 4.5",
89
+ "family": "claude_haiku",
90
+ "context_window": 200000,
91
+ "max_output_tokens": 64000,
92
+ "capabilities": [
93
+ "function_calling",
94
+ "streaming",
95
+ "structured_output",
96
+ "vision",
97
+ "reasoning",
98
+ "prompt_caching",
99
+ "tool_choice",
100
+ "parallel_tool_calls"
101
+ ],
102
+ "modalities": {
103
+ "input": [
104
+ "text",
105
+ "image",
106
+ "pdf"
107
+ ],
108
+ "output": [
109
+ "text"
110
+ ]
111
+ },
112
+ "provider": "anthropic"
113
+ },
114
+ {
115
+ "id": "claude-opus-4-5",
116
+ "name": "Claude Opus 4.5",
117
+ "family": "claude_opus",
118
+ "context_window": 200000,
119
+ "max_output_tokens": 64000,
120
+ "capabilities": [
121
+ "function_calling",
122
+ "streaming",
123
+ "structured_output",
124
+ "vision",
125
+ "reasoning",
126
+ "prompt_caching",
127
+ "tool_choice",
128
+ "parallel_tool_calls"
129
+ ],
130
+ "modalities": {
131
+ "input": [
132
+ "text",
133
+ "image",
134
+ "pdf"
135
+ ],
136
+ "output": [
137
+ "text"
138
+ ]
139
+ },
140
+ "provider": "anthropic"
141
+ },
142
+ {
143
+ "id": "claude-3.5-sonnet",
144
+ "name": "Claude 3.5 Sonnet",
145
+ "family": "claude_sonnet",
146
+ "context_window": 200000,
147
+ "max_output_tokens": 8192,
148
+ "capabilities": [
149
+ "function_calling",
150
+ "streaming",
151
+ "vision",
152
+ "reasoning"
153
+ ],
154
+ "modalities": {
155
+ "input": [
156
+ "text",
157
+ "image"
158
+ ],
159
+ "output": [
160
+ "text"
161
+ ]
162
+ },
163
+ "provider": "anthropic"
164
+ },
165
+ {
166
+ "id": "claude-3.5-haiku",
167
+ "name": "Claude 3.5 Haiku",
168
+ "family": "claude_haiku",
169
+ "context_window": 200000,
170
+ "max_output_tokens": 8192,
171
+ "capabilities": [
172
+ "function_calling",
173
+ "streaming",
174
+ "vision",
175
+ "reasoning"
176
+ ],
177
+ "modalities": {
178
+ "input": [
179
+ "text",
180
+ "image"
181
+ ],
182
+ "output": [
183
+ "text"
184
+ ]
185
+ },
186
+ "provider": "anthropic"
187
+ }
188
+ ]
@@ -0,0 +1 @@
1
+ []
@@ -0,0 +1 @@
1
+ []
@@ -0,0 +1,85 @@
1
+ [
2
+ {
3
+ "id": "deepseek-v4-flash",
4
+ "name": "DeepSeek V4 Flash",
5
+ "family": "deepseek_v4",
6
+ "context_window": 1000000,
7
+ "max_output_tokens": 384000,
8
+ "capabilities": [
9
+ "function_calling",
10
+ "streaming",
11
+ "reasoning"
12
+ ],
13
+ "modalities": {
14
+ "input": [
15
+ "text"
16
+ ],
17
+ "output": [
18
+ "text"
19
+ ]
20
+ },
21
+ "provider": "deepseek"
22
+ },
23
+ {
24
+ "id": "deepseek-v4-pro",
25
+ "name": "DeepSeek V4 Pro",
26
+ "family": "deepseek_v4",
27
+ "context_window": 1000000,
28
+ "max_output_tokens": 384000,
29
+ "capabilities": [
30
+ "function_calling",
31
+ "streaming",
32
+ "reasoning"
33
+ ],
34
+ "modalities": {
35
+ "input": [
36
+ "text"
37
+ ],
38
+ "output": [
39
+ "text"
40
+ ]
41
+ },
42
+ "provider": "deepseek"
43
+ },
44
+ {
45
+ "id": "deepseek-chat",
46
+ "name": "DeepSeek Chat",
47
+ "family": "deepseek_v3",
48
+ "context_window": 64000,
49
+ "max_output_tokens": 8192,
50
+ "capabilities": [
51
+ "function_calling",
52
+ "streaming"
53
+ ],
54
+ "modalities": {
55
+ "input": [
56
+ "text"
57
+ ],
58
+ "output": [
59
+ "text"
60
+ ]
61
+ },
62
+ "provider": "deepseek"
63
+ },
64
+ {
65
+ "id": "deepseek-reasoner",
66
+ "name": "DeepSeek Reasoner",
67
+ "family": "deepseek_r1",
68
+ "context_window": 64000,
69
+ "max_output_tokens": 8192,
70
+ "capabilities": [
71
+ "function_calling",
72
+ "streaming",
73
+ "reasoning"
74
+ ],
75
+ "modalities": {
76
+ "input": [
77
+ "text"
78
+ ],
79
+ "output": [
80
+ "text"
81
+ ]
82
+ },
83
+ "provider": "deepseek"
84
+ }
85
+ ]