ask-llm-providers 0.2.2 → 0.3.0

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: 925a11549bdce89470327b583421ae71aa3b8aa4385efacb9baead5ee8aa7f27
4
+ data.tar.gz: 8ec764b1b392cf83ac70afb56eb78df2e118150f2e25951580dd1efac628a8af
5
5
  SHA512:
6
- metadata.gz: 8999b96cbe13c709393c4208be9e49513059f0e239da1755e4061be85d3084ae4bcd52418cfcbac6add0f2ded90ad58e43833f2e49488893151f4aea989893f6
7
- data.tar.gz: 2b5dac9e60b02a4c28e8f406159064454b068c2fe45c215820629bd0e02f5d0da8f81d090d142f1ee0d0a91d68a37b267853327617cf34e8219bc0ec780ae39c
6
+ metadata.gz: 4bff20df39628b886645cb48d8eb812d6f5e32ae8f5de2ffc433bd577c7e5dd4bf64b429b0efeffefbee79f2ce7af3bdc48a5cabb60f63e0ea0612e61d352130
7
+ data.tar.gz: 93bef4f89008fbcefcee9362ef9ca35369230012777e9202683d781ebdf872901d796fb847f2cbd74cf86faaa17801233bcaa2731f6058ee72ef45f6d7dadc2c
data/CHANGELOG.md CHANGED
@@ -1,4 +1,21 @@
1
- ## [0.2.2] - 2026-06-25
1
+ ## [0.3.0] 2026-07-14
2
+
3
+ ### Added
4
+ - **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.
5
+ - **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.
6
+ - **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.
7
+ - **User config support** — `~/.ask-llm-providers/models.json` overrides bundled model fields or adds custom models.
8
+ - **`opencode.json` includes `deepseek-v4-flash`** — matches the default model configuration.
9
+
10
+ ### Changed
11
+ - Removed hardcoded `Ask::LLM::Models::OPENAI_MODELS` constants — replaced with catalog-driven model loading.
12
+ - `Ask::LLM::Aliases.resolve` now aliases `deepseek-v4` → `deepseek-v4-flash`, `gpt-4o-latest` → `gpt-4o`, `gpt-4.1-latest` → `gpt-4.1`.
13
+
14
+ ### Fixed
15
+ - Model entries now include `"provider"` field in JSON files (was missing from generated data).
16
+ - User config merges properly override bundled values (was keeping old values on conflict).
17
+
18
+ ## [0.2.2] — 2026-06-25
2
19
 
3
20
  ### Changed
4
21
  - 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,194 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "fileutils"
5
+
6
+ module Ask
7
+ module LLM
8
+ # Orchestrates model catalog loading from multiple sources:
9
+ #
10
+ # 1. Bundled JSON files in lib/ask/llm/models/*.json (shipped with the gem)
11
+ # 2. ~/.ask-llm-providers/models.json (user-defined overrides)
12
+ # 3. Provider API list_models() calls (on explicit refresh!)
13
+ #
14
+ # Loaded models are registered into Ask::ModelCatalog for use
15
+ # by ask-agent, ask-mcp, and llm-proxy.
16
+ #
17
+ # Ask::LLM::Catalog.load! # load bundled + user config
18
+ # Ask::LLM::Catalog.refresh! # also fetch from provider APIs
19
+ #
20
+ class Catalog
21
+ class Error < StandardError; end
22
+ class LoadError < Error; end
23
+
24
+ USER_CONFIG_PATH = File.expand_path("~/.ask-llm-providers/models.json").freeze
25
+
26
+ class << self
27
+ # Load bundled model definitions and user overrides into Ask::ModelCatalog.
28
+ # Idempotent — subsequent calls clear and reload.
29
+ def load!
30
+ instance.clear
31
+ instance.load_bundled
32
+ instance.load_user_config
33
+ instance.register_all
34
+ true
35
+ end
36
+
37
+ # Like load! but also fetches model lists from configured providers'
38
+ # list_models() APIs. Unknown models are added with minimal metadata.
39
+ def refresh!
40
+ load!
41
+ instance.fetch_from_providers
42
+ instance.register_all
43
+ true
44
+ end
45
+
46
+ private
47
+
48
+ def symbolize_keys(hash)
49
+ hash.transform_keys { |k| k.respond_to?(:to_sym) ? k.to_sym : k }
50
+ end
51
+
52
+ def instance
53
+ @instance ||= new
54
+ end
55
+ end
56
+
57
+ def initialize
58
+ @entries = []
59
+ @model_keys = Set.new
60
+ end
61
+
62
+ def clear
63
+ @entries.clear
64
+ @model_keys.clear
65
+ end
66
+
67
+ # Load bundled model JSONs from the gem's lib/ask/llm/models/ directory.
68
+ def load_bundled
69
+ pattern = File.expand_path("models/*.json", __dir__)
70
+ Dir[pattern].sort.each do |path|
71
+ raw = JSON.parse(File.read(path))
72
+ raw.each { |entry| add_entry(entry) }
73
+ end
74
+ end
75
+
76
+ # Load user-defined model overrides from ~/.ask-llm-providers/models.json.
77
+ # Silently skipped if the file doesn't exist.
78
+ def load_user_config
79
+ path = USER_CONFIG_PATH
80
+ return unless File.exist?(path)
81
+
82
+ raw = JSON.parse(File.read(path))
83
+ unless raw.is_a?(Array)
84
+ warn "Warning: #{path} should be a JSON array of model entries, got #{raw.class}"
85
+ return
86
+ end
87
+
88
+ raw.each { |entry| merge_or_add(entry) }
89
+ rescue JSON::ParserError => e
90
+ warn "Warning: Failed to parse #{path}: #{e.message}"
91
+ end
92
+
93
+ # Fetch model lists from all configured providers via their list_models() API.
94
+ # Adds unknown models with minimal metadata (no capability guessing).
95
+ def fetch_from_providers
96
+ Ask::Provider.providers.each do |slug, provider_class|
97
+ next unless provider_class.configured?(nil)
98
+
99
+ begin
100
+ provider = provider_class.new
101
+ models = provider.list_models
102
+ models.each do |m|
103
+ add_entry(m) unless @model_keys.include?([m[:id], slug.to_s])
104
+ end
105
+ rescue StandardError => e
106
+ warn "Warning: Failed to fetch models from #{slug}: #{e.message}"
107
+ end
108
+ end
109
+ end
110
+
111
+ # Register all accumulated entries into Ask::ModelCatalog.
112
+ # Also registers alias entries so models can be found by alias name.
113
+ def register_all
114
+ @entries.each do |entry|
115
+ info = build_model_info(entry)
116
+ Ask::ModelCatalog.instance.register(info)
117
+ end
118
+
119
+ register_alias_entries
120
+ end
121
+
122
+ private
123
+
124
+ # For each alias (short_name → canonical_id), register a duplicate
125
+ # ModelInfo for every canonical entry whose id matches.
126
+ def register_alias_entries
127
+ Ask::LLM::Aliases.all.each do |short_name, canonical_id|
128
+ next if short_name == canonical_id
129
+
130
+ @entries.each do |entry|
131
+ next unless entry["id"] == canonical_id || entry[:id] == canonical_id
132
+
133
+ alias_entry = entry.merge("id" => short_name)
134
+ info = build_model_info(alias_entry)
135
+ Ask::ModelCatalog.instance.register(info)
136
+ end
137
+ end
138
+ end
139
+
140
+ def symbolize_keys(hash)
141
+ hash.transform_keys { |k| k.respond_to?(:to_sym) ? k.to_sym : k }
142
+ end
143
+
144
+ def add_entry(entry)
145
+ key = entry_key(entry)
146
+ return if @model_keys.include?(key)
147
+
148
+ @entries << entry
149
+ @model_keys << key
150
+ end
151
+
152
+ def merge_or_add(entry)
153
+ key = entry_key(entry)
154
+ existing = @entries.find { |e| entry_key(e) == key }
155
+
156
+ if existing
157
+ existing.merge!(entry)
158
+ else
159
+ @entries << entry
160
+ @model_keys << key
161
+ end
162
+ end
163
+
164
+ def entry_key(entry)
165
+ id = entry["id"] || entry[:id]
166
+ provider = entry["provider"] || entry[:provider]
167
+ [id, provider.to_s]
168
+ end
169
+
170
+ def build_model_info(entry)
171
+ e = entry.transform_keys(&:to_sym)
172
+ modalities = e[:modalities]
173
+ modalities = symbolize_keys(modalities) if modalities
174
+
175
+ Ask::ModelInfo.new(
176
+ id: e[:id],
177
+ name: e[:name] || e[:id],
178
+ provider: e[:provider],
179
+ family: e[:family],
180
+ capabilities: Array(e[:capabilities]),
181
+ context_window: e[:context_window],
182
+ max_output_tokens: e[:max_output_tokens],
183
+ modalities: modalities || { input: %w[text], output: %w[text] },
184
+ pricing: e[:pricing] || {},
185
+ knowledge_cutoff: e[:knowledge_cutoff] ? Date.parse(e[:knowledge_cutoff].to_s) : nil,
186
+ created_at: e[:created_at] ? Date.parse(e[:created_at].to_s) : nil,
187
+ metadata: (e[:metadata] || {}).merge(source: e[:metadata]&.dig("source") || "bundled")
188
+ )
189
+ rescue Date::Error
190
+ Ask::ModelInfo.new(id: e[:id], provider: e[:provider])
191
+ end
192
+ end
193
+ end
194
+ 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
+ ]