ask-llm-providers 0.6.1 → 0.8.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: 915a6861e0cbc8cdddcc9f1b9f936d9f95896943f1307f199dfe604b8f4d527c
4
- data.tar.gz: 9f667d8f56d4e1265314c4dfdeaebbefbab7dae0a74724dc31ac79074decf3ef
3
+ metadata.gz: 3cd9e34fd4befbdd393f4b4496b0d0e5dde254dc2ef10c3d86bb7043f81406de
4
+ data.tar.gz: 6de8ecfb85b0aa79f5623a0968ea8a787dc8c64ebce9da6d5d49122ef82e037c
5
5
  SHA512:
6
- metadata.gz: 528d7105a625887f249e0fe7a0f0dad3469e47f73cfad59d02d85613cdecb287d9277c007cc50b1fc574053fc44ac27b6fa198cf3221ec0de8881dca2be65b33
7
- data.tar.gz: b6d3e1dc25804a7808e7cfa45ec2ff7f9542ca43fad5336a7131c7dd18930264badcf9575f0ee993781f7ced64cd9dfa496a8e14851b4f73e60fa0db4f1c780f
6
+ metadata.gz: 10878f289f4fc79c814e839554adf05f7931e5a4eaa100cc5b584a20ee0bc16fece12c65472af23872abb30885e4e1cbdfc05310baffce4cd4f1dc4be60a622d
7
+ data.tar.gz: c2fbe34457dcd128b2601baf756257d6147761505150b700d988d130e580017b9e6839ad9b5ba0ea6f91ec398ec1e09ae2bf3c363c2b6508a2d44a9c16368235
data/CHANGELOG.md CHANGED
@@ -1,3 +1,37 @@
1
+ ## [0.8.0] — 2026-07-17
2
+
3
+ ### Added
4
+
5
+ - **OpenRouter model source** (`Ask::LLM::Sources::OpenRouter`) — fetches model data from OpenRouter API and fills gaps that models.dev doesn't cover. Adds models for providers like Groq, Together, Fireworks, Cerebras, Meta, Moonshot, Nvidia NIM that aren't in models.dev. Merges with existing models.dev data — models.dev takes priority for overlapping models.
6
+ - **`CostCalculator.per_million`** — returns per-million token rates for quick display: `{ input: 2.5, output: 10.0, cache_read: 1.25 }`.
7
+ - **Audio token costing** — `calculate` and `breakdown` now accept `audio_input_tokens` and `audio_output_tokens` parameters. Costs are computed from `audio_tokens` pricing data.
8
+ - **Tiered pricing** — both `calculate` and `breakdown` accept a `tier:` parameter (`:standard` or `:batch`) that selects the appropriate rate tier.
9
+ - **`rake models:update`** — now fetches both models.dev and OpenRouter in sequence.
10
+
11
+ ### Changed
12
+
13
+ - **Model coverage: 62 → 406 models** across 12 providers, with 397 (98%) having full pricing data.
14
+ - **OpenRouter source added** — providers without models.dev coverage (meta, moonshot, nvidia_nim) now have bundled models.
15
+ - **`build_model_info`** — pricing hashes are deep-symbolized. `Date.parse` failures handled gracefully via `safe_parse_date`.
16
+
17
+ ## [0.7.0] — 2026-07-17
18
+
19
+ ### Added
20
+
21
+ - **`Ask::LLM::Sources::ModelsDev`** — fetches model data from `models.dev` API and writes enriched per-provider JSON files with pricing, capabilities, and modalities. Run `rake models:update` before each release to keep bundled model data current.
22
+ - **`Ask::LLM::CostCalculator`** — calculates LLM API costs from model pricing data. Supports input, output, cache read/write, and reasoning tokens.
23
+
24
+ ### Changed
25
+
26
+ - **Model coverage expanded** — from 62 to 289 models across 10 providers, with 284 (98%) having full pricing data. Generated from models.dev API instead of hand-written.
27
+ - **`build_model_info` now deep-symbolizes pricing keys** — pricing hashes loaded from JSON now use symbol keys (`:text_tokens`, `:standard`, `:input_per_million`) matching the format produced by `ModelsDevParser` in ask-core.
28
+ - **`build_model_info` handles date parsing safely** — `Date.parse` failures no longer silently destroy the entire model entry via a broad `rescue Date::Error`. Invalid dates are gracefully set to `nil` via `safe_parse_date`.
29
+
30
+ ### Fixed
31
+
32
+ - **Pricing data loss bug** — `rescue Date::Error` in `build_model_info` was catching exceptions from the entire method body, including date parsing and pricing construction. When any model had an unparseable date, its ModelInfo was created with only `id` and `provider`, silently discarding pricing, capabilities, modalities, and all other fields.
33
+ - **Pricing key inconsistency** — pricing loaded from JSON had string keys while pricing from `ModelsDevParser` (ask-core) had symbol keys. Both formats now consistently use symbol keys.
34
+
1
35
  ## [0.6.1] — 2026-07-17
2
36
 
3
37
  ### Added
@@ -35,6 +35,8 @@ module Ask
35
35
  true
36
36
  end
37
37
 
38
+
39
+
38
40
  # Like load! but also fetches model lists from configured providers'
39
41
  # list_models() APIs. Unknown models are added with minimal metadata.
40
42
  def refresh!
@@ -112,9 +114,12 @@ module Ask
112
114
  # Register all accumulated entries into Ask::ModelCatalog.
113
115
  # Also registers alias entries so models can be found by alias name.
114
116
  def register_all
117
+ catalog = Ask::ModelCatalog.instance
118
+ catalog.instance_variable_set(:@models, [])
119
+
115
120
  @entries.each do |entry|
116
121
  info = build_model_info(entry)
117
- Ask::ModelCatalog.instance.register(info)
122
+ catalog.register(info)
118
123
  end
119
124
 
120
125
  register_alias_entries
@@ -142,6 +147,13 @@ module Ask
142
147
  hash.transform_keys { |k| k.respond_to?(:to_sym) ? k.to_sym : k }
143
148
  end
144
149
 
150
+ def deep_symbolize_keys(hash)
151
+ hash.each_with_object({}) { |(k, v), h|
152
+ hk = k.respond_to?(:to_sym) ? k.to_sym : k
153
+ h[hk] = v.is_a?(Hash) ? deep_symbolize_keys(v) : v
154
+ }
155
+ end
156
+
145
157
  def add_entry(entry)
146
158
  key = entry_key(entry)
147
159
  return if @model_keys.include?(key)
@@ -170,8 +182,16 @@ module Ask
170
182
 
171
183
  def build_model_info(entry)
172
184
  e = entry.transform_keys(&:to_sym)
173
- modalities = e[:modalities]
174
- modalities = symbolize_keys(modalities) if modalities
185
+
186
+ modalities = symbolize_keys(e[:modalities]) if e[:modalities]
187
+
188
+ pricing = {}
189
+ if e[:pricing] && e[:pricing].any?
190
+ deep_symbolize_keys(e[:pricing]).each { |k, v| pricing[k] = v }
191
+ end
192
+
193
+ knowledge_cutoff = safe_parse_date(e[:knowledge_cutoff])
194
+ created_at = safe_parse_date(e[:created_at])
175
195
 
176
196
  Ask::ModelInfo.new(
177
197
  id: e[:id],
@@ -182,13 +202,19 @@ module Ask
182
202
  context_window: e[:context_window],
183
203
  max_output_tokens: e[:max_output_tokens],
184
204
  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,
205
+ pricing: pricing,
206
+ knowledge_cutoff: knowledge_cutoff,
207
+ created_at: created_at,
188
208
  metadata: (e[:metadata] || {}).merge(source: e[:metadata]&.dig("source") || "bundled")
189
209
  )
190
- rescue Date::Error
191
- Ask::ModelInfo.new(id: e[:id], provider: e[:provider])
210
+ end
211
+
212
+ def safe_parse_date(value)
213
+ return nil if value.nil?
214
+ return value if value.is_a?(Date)
215
+ Date.parse(value.to_s)
216
+ rescue ArgumentError
217
+ nil
192
218
  end
193
219
  end
194
220
  end
@@ -3,79 +3,107 @@
3
3
  module Ask
4
4
  module LLM
5
5
  # Calculate LLM API costs from model pricing data.
6
- #
7
- # Works with any object responding to +#pricing+ that returns a hash
8
- # in the standard ask-rb pricing format:
9
- #
10
- # {
11
- # text_tokens: {
12
- # standard: {
13
- # input_per_million: 2.5,
14
- # output_per_million: 10.0,
15
- # cache_read_input_per_million: 1.25,
16
- # cache_write_input_per_million: 5.0,
17
- # reasoning_output_per_million: 15.0
18
- # }
19
- # },
20
- # audio_tokens: { standard: { input_per_million: 100.0, output_per_million: 200.0 } }
21
- # }
22
- #
23
6
  module CostCalculator
24
7
  MILLION = 1_000_000
25
8
 
26
9
  class << self
27
- # Calculate the total cost in USD for a model invocation.
10
+ # Calculate total cost in USD for a model invocation.
28
11
  #
29
- # @param model [Ask::ModelInfo, #pricing] the model
12
+ # @param model [Ask::ModelInfo, #pricing] the model or a pricing hash
30
13
  # @param input_tokens [Integer]
31
14
  # @param output_tokens [Integer]
32
15
  # @param cache_read_tokens [Integer]
33
16
  # @param cache_write_tokens [Integer]
34
- # @param reasoning_tokens [Integer] tokens billed at reasoning rate
17
+ # @param reasoning_tokens [Integer]
18
+ # @param audio_input_tokens [Integer]
19
+ # @param audio_output_tokens [Integer]
20
+ # @param tier [Symbol] pricing tier (:standard or :batch)
35
21
  # @return [Float, nil] cost in USD, or nil if no pricing data
36
22
  def calculate(model, input_tokens: 0, output_tokens: 0,
37
23
  cache_read_tokens: 0, cache_write_tokens: 0,
38
- reasoning_tokens: 0)
39
- pricing = model.respond_to?(:pricing) ? model.pricing : model
40
- rates = pricing.dig(:text_tokens, :standard) or return nil
24
+ reasoning_tokens: 0,
25
+ audio_input_tokens: 0, audio_output_tokens: 0,
26
+ tier: :standard)
27
+ pricing = extract_pricing(model)
28
+ return nil unless pricing
29
+
30
+ rates = pricing.dig(:text_tokens, tier) or return nil
41
31
 
42
32
  sum = cost(input_tokens, rates[:input_per_million])
43
33
  sum += cost(output_tokens, rates[:output_per_million])
34
+ sum += cost(cache_read_tokens, rates[:cache_read_input_per_million])
35
+ sum += cost(cache_write_tokens, rates[:cache_write_input_per_million])
44
36
 
45
- if cache_read_tokens > 0
46
- sum += cost(cache_read_tokens, rates[:cache_read_input_per_million])
47
- end
48
- if cache_write_tokens > 0
49
- sum += cost(cache_write_tokens, rates[:cache_write_input_per_million])
50
- end
51
37
  if reasoning_tokens > 0
52
38
  rate = rates[:reasoning_output_per_million] || rates[:output_per_million]
53
39
  sum += cost(reasoning_tokens, rate)
54
40
  end
55
41
 
42
+ if audio_input_tokens > 0 || audio_output_tokens > 0
43
+ audio = pricing.dig(:audio_tokens, tier)
44
+ sum += cost(audio_input_tokens, audio[:input_per_million]) if audio
45
+ sum += cost(audio_output_tokens, audio[:output_per_million]) if audio
46
+ end
47
+
56
48
  sum
57
49
  end
58
50
 
59
- # Cost breakdown with individual components.
51
+ # Per-million rates for quick display.
52
+ #
53
+ # @param model [Ask::ModelInfo, #pricing]
54
+ # @param tier [Symbol] (:standard or :batch)
55
+ # @return [Hash, nil]
56
+ def per_million(model, tier: :standard)
57
+ pricing = extract_pricing(model)
58
+ return nil unless pricing
59
+
60
+ rates = pricing.dig(:text_tokens, tier) or return nil
61
+
62
+ {
63
+ input: rates[:input_per_million],
64
+ output: rates[:output_per_million],
65
+ cache_read: rates[:cache_read_input_per_million],
66
+ cache_write: rates[:cache_write_input_per_million],
67
+ reasoning: rates[:reasoning_output_per_million]
68
+ }.compact
69
+ end
70
+
71
+ # Per-component cost breakdown.
60
72
  #
61
73
  # @return [Hash, nil]
62
74
  def breakdown(model, input_tokens: 0, output_tokens: 0,
63
75
  cache_read_tokens: 0, cache_write_tokens: 0,
64
- reasoning_tokens: 0)
65
- pricing = model.respond_to?(:pricing) ? model.pricing : model
66
- rates = pricing.dig(:text_tokens, :standard) or return nil
76
+ reasoning_tokens: 0,
77
+ audio_input_tokens: 0, audio_output_tokens: 0,
78
+ tier: :standard)
79
+ pricing = extract_pricing(model)
80
+ return nil unless pricing
67
81
 
68
- {
82
+ rates = pricing.dig(:text_tokens, tier) or return nil
83
+
84
+ result = {
69
85
  input: cost(input_tokens, rates[:input_per_million]),
70
86
  output: cost(output_tokens, rates[:output_per_million]),
71
- cache_read: cache_read_tokens > 0 ? cost(cache_read_tokens, rates[:cache_read_input_per_million]) : 0,
72
- cache_write: cache_write_tokens > 0 ? cost(cache_write_tokens, rates[:cache_write_input_per_million]) : 0,
73
- reasoning: reasoning_tokens > 0 ? cost(reasoning_tokens, rates[:reasoning_output_per_million] || rates[:output_per_million]) : 0
87
+ cache_read: cost(cache_read_tokens, rates[:cache_read_input_per_million]),
88
+ cache_write: cost(cache_write_tokens, rates[:cache_write_input_per_million]),
89
+ reasoning: cost(reasoning_tokens, rates[:reasoning_output_per_million] || rates[:output_per_million])
74
90
  }.compact
91
+
92
+ if audio_input_tokens > 0 || audio_output_tokens > 0
93
+ audio = pricing.dig(:audio_tokens, tier)
94
+ result[:audio_input] = cost(audio_input_tokens, audio[:input_per_million]) if audio
95
+ result[:audio_output] = cost(audio_output_tokens, audio[:output_per_million]) if audio
96
+ end
97
+
98
+ result
75
99
  end
76
100
 
77
101
  private
78
102
 
103
+ def extract_pricing(model)
104
+ model.respond_to?(:pricing) ? model.pricing : model
105
+ end
106
+
79
107
  def cost(tokens, rate)
80
108
  return 0.0 unless rate && tokens > 0
81
109
  (tokens * rate) / MILLION.to_f