ask-llm-providers 0.1.2 → 0.8.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.
Files changed (39) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +181 -0
  3. data/lib/ask/llm/aliases.json +11 -0
  4. data/lib/ask/llm/aliases.rb +62 -0
  5. data/lib/ask/llm/catalog.rb +221 -0
  6. data/lib/ask/llm/cost_calculator.rb +114 -0
  7. data/lib/ask/llm/http.rb +68 -12
  8. data/lib/ask/llm/models/anthropic.json +872 -0
  9. data/lib/ask/llm/models/bedrock.json +3769 -0
  10. data/lib/ask/llm/models/deepseek.json +340 -0
  11. data/lib/ask/llm/models/gemini.json +1211 -0
  12. data/lib/ask/llm/models/meta.json +299 -0
  13. data/lib/ask/llm/models/mistral.json +1303 -0
  14. data/lib/ask/llm/models/moonshot.json +198 -0
  15. data/lib/ask/llm/models/nvidia_nim.json +289 -0
  16. data/lib/ask/llm/models/openai.json +2521 -0
  17. data/lib/ask/llm/models/opencode.json +552 -0
  18. data/lib/ask/llm/models/perplexity.json +148 -0
  19. data/lib/ask/llm/models/vertex_ai.json +1347 -0
  20. data/lib/ask/llm/models/xai.json +326 -0
  21. data/lib/ask/llm/models_schema.json +74 -0
  22. data/lib/ask/llm/openai_compatible.rb +100 -0
  23. data/lib/ask/llm/provider_config.rb +65 -0
  24. data/lib/ask/llm/sources/models_dev.rb +154 -0
  25. data/lib/ask/llm/sources/openrouter.rb +176 -0
  26. data/lib/ask/llm/sse_buffer.rb +43 -0
  27. data/lib/ask/llm/version.rb +1 -1
  28. data/lib/ask/provider/anthropic.rb +106 -103
  29. data/lib/ask/provider/bedrock.rb +84 -52
  30. data/lib/ask/provider/cloudflare.rb +62 -40
  31. data/lib/ask/provider/google.rb +112 -74
  32. data/lib/ask/provider/mistral.rb +156 -6
  33. data/lib/ask/provider/ollama.rb +83 -29
  34. data/lib/ask/provider/openai.rb +141 -68
  35. data/lib/ask/provider/openai_compatible.rb +91 -0
  36. data/lib/ask/skills/providers.model_select/SKILL.md +164 -0
  37. data/lib/ask-llm-providers.rb +22 -18
  38. metadata +35 -7
  39. data/lib/ask/llm/models/openai.rb +0 -69
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b0b23746b8ee8cc98e50c44f9e88df977ea81d9214bbda9a25be770021234cc2
4
- data.tar.gz: 2a2e628b12eb8e731ea9d46ae0f4076dd42591f03db0cf50759d77d6485e748e
3
+ metadata.gz: fcbf569b97e70a5c06deac427eb7cc500bb959295b2f8ce10ed20d9698cec241
4
+ data.tar.gz: 20a6ac87a6a4e35d84d3596d604644e2280616676399632ce2b481c3591a96f2
5
5
  SHA512:
6
- metadata.gz: 4e31b5f82ae3aaab7a7bf337df1ebd5afc20b272efc2b630523145bbf82ebbd12faddcaacdb30e531df3cd3a379a82d9d2e96b97d9ff33877eff6eb0c638d320
7
- data.tar.gz: 2db527541cb1a8934c6f5ff2e62fbb8ebddf633600e332b040231461eaf9c9b5cb37ec3a70f0079a85404cf8b5fb3751c4f9b88ef075366681044d046ebfdd6b
6
+ metadata.gz: 57f783f4c2bdd7335e45d06f177df09ea565acec8cf65d4cc9bdadb747276b94e944cdacf0505dc29e553697ad21ef7574a357c1d7ac5f5386bc59c76fa4b835
7
+ data.tar.gz: d4ffb871a8e1702aaa1b27763bb1481689f1c61f8c55efd27de0db198cf3f9482ca691c5c0e36c0806cf11057178418a3cfd467016afd79207d40dbe39214328
data/CHANGELOG.md ADDED
@@ -0,0 +1,181 @@
1
+ ## [0.8.1] — 2026-07-17
2
+
3
+ ### Added
4
+
5
+ - **Rich error categories in HTTP mapper** — `Ask::LLM::HTTP.map_error` now sets `category`, `rate_limit_type`, and `retry_after` on `RateLimitError` instances. Rate limit type is detected from error message keywords (token, budget, concurrent, requests). `retry_after` is extracted from response headers.
6
+
7
+ ## [0.8.0] — 2026-07-17
8
+
9
+ ### Added
10
+
11
+ - **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.
12
+ - **`CostCalculator.per_million`** — returns per-million token rates for quick display: `{ input: 2.5, output: 10.0, cache_read: 1.25 }`.
13
+ - **Audio token costing** — `calculate` and `breakdown` now accept `audio_input_tokens` and `audio_output_tokens` parameters. Costs are computed from `audio_tokens` pricing data.
14
+ - **Tiered pricing** — both `calculate` and `breakdown` accept a `tier:` parameter (`:standard` or `:batch`) that selects the appropriate rate tier.
15
+ - **`rake models:update`** — now fetches both models.dev and OpenRouter in sequence.
16
+
17
+ ### Changed
18
+
19
+ - **Model coverage: 62 → 406 models** across 12 providers, with 397 (98%) having full pricing data.
20
+ - **OpenRouter source added** — providers without models.dev coverage (meta, moonshot, nvidia_nim) now have bundled models.
21
+ - **`build_model_info`** — pricing hashes are deep-symbolized. `Date.parse` failures handled gracefully via `safe_parse_date`.
22
+
23
+ ## [0.7.0] — 2026-07-17
24
+
25
+ ### Added
26
+
27
+ - **`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.
28
+ - **`Ask::LLM::CostCalculator`** — calculates LLM API costs from model pricing data. Supports input, output, cache read/write, and reasoning tokens.
29
+
30
+ ### Changed
31
+
32
+ - **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.
33
+ - **`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.
34
+ - **`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`.
35
+
36
+ ### Fixed
37
+
38
+ - **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.
39
+ - **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.
40
+
41
+ ## [0.6.1] — 2026-07-17
42
+
43
+ ### Added
44
+
45
+ - **`Ask::LLM::CostCalculator`** — calculate LLM API costs from model pricing data. Supports input, output, cache read/write, and reasoning tokens. Returns cost in USD or nil if no pricing data available. Works with any object responding to `#pricing` (Ask::ModelInfo, raw hash, etc.).
46
+ - **`CostCalculator.breakdown`** — returns a component-by-component cost breakdown hash.
47
+
48
+ ## [0.6.0] — 2026-07-17
49
+
50
+ ### Added
51
+
52
+ - **14 new OpenAI-compatible providers** — aiml, ai21, anyscale, deepinfra, featherless, friendli, github, hyperbolic, meta, nebius, novita, nscale, nvidia_nim, sambanova. Each is one line in the registry. Total OpenAI-compatible providers: 26. Total providers: 33.
53
+ - **Auto-generated tests** — `OpenAICompatibleTest` now builds its test list from `OPENAI_COMPATIBLE` dynamically. Adding a provider automatically generates 5 identity tests (registered, slug, capabilities, api_base, requires_api_key).
54
+
55
+ ## [0.5.0] — 2026-07-17
56
+
57
+ ### Added
58
+
59
+ - **OpenAI-compatible provider registry** (`Ask::LLM::OPENAI_COMPATIBLE`) — 12 providers defined as data, not classes. Adding a new OpenAI-compatible API (Groq, Together, Fireworks, etc.) is one line in the registry. No new file, no subclass.
60
+ - **`Ask::Providers::OpenAICompatible` class** — single class handling all registered providers by reading from the registry. Each provider gets an anonymous subclass with its slug, api_base, env var mapping, and quirks (reasoning_content, extra_headers) set from config.
61
+ - **Unified test** — `OpenAICompatibleTest` dynamically tests every registered provider: identity, slug, capabilities, api_base, env var resolution, request building, response parsing, streaming, and tool formatting.
62
+
63
+ ### Removed
64
+
65
+ - **5 subclass files** — `deepseek.rb`, `openrouter.rb`, `opencode.rb`, `opencode_go.rb`, `mimo.rb` deleted. Replaced by registry entries.
66
+ - **DeepSeek-specific test file** — covered by the unified test.
67
+
68
+ ## [0.4.0] — 2026-07-16
69
+
70
+ ### Added
71
+
72
+ - **`Ask::LLM::ProviderConfig` transformation contract** — Shared module that every provider includes, defining the wire-format interface: `build_request`, `parse_response`, `parse_stream`, `format_tools`, `format_message`. Adding a new provider is now mechanical — implement five methods and the provider works. (Inspired by LiteLLM's `BaseConfig` pattern.)
73
+ - **`BaseProviderTests` shared test module** — Every provider test includes this module, which enforces 22 contract tests (interface methods, slug, capabilities, config, request building, error mapping) inherited from LiteLLM's `BaseLLMChatTest` approach. Adding a new provider gives you 22 tests for free.
74
+ - **Comprehensive per-provider tests** — Each provider now has dedicated tests for `build_request`, `parse_response`, `parse_stream`, `format_message`, `format_tools`, `parse_error`, and streaming — covering happy paths, edge cases, and error conditions. Total test count: 341 (up from ~33 in v0.1.0).
75
+
76
+ ### Changed
77
+
78
+ - **Provider refactoring** — OpenAI, Anthropic, Google, Bedrock, Ollama, Cloudflare, and Mistral providers now include `Ask::LLM::ProviderConfig` and implement its transformation contract. The `chat` method in each is a clean orchestrator: build request → HTTP → parse response. Internal methods (`build_chat_payload`, `process_chunk`, etc.) are renamed to the contract standard.
79
+ - **Subclass compatibility** — DeepSeek, OpenRouter, OpenCode, OpenCodeGo, and Mimo (all OpenAI subclasses) inherit the transformation contract unchanged. Their `format_messages` overrides continue to work through `build_request`.
80
+
81
+ ### Removed
82
+
83
+ - **Dead files** — Removed `lib/ask/provider/config.rb` (moved to `lib/ask/llm/provider_config.rb` to avoid namespace collision with `Ask::Provider` class).
84
+
85
+ ## [0.3.1] — 2026-07-14
86
+
87
+ ### Removed
88
+ - `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.
89
+
90
+ ## [0.3.0] — 2026-07-14
91
+
92
+ ### Added
93
+ - **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.
94
+ - **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.
95
+ - **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.
96
+ - **User config support** — `~/.ask-llm-providers/models.json` overrides bundled model fields or adds custom models.
97
+ - **`opencode.json` includes `deepseek-v4-flash`** — matches the default model configuration.
98
+
99
+ ### Changed
100
+ - Removed hardcoded `Ask::LLM::Models::OPENAI_MODELS` constants — replaced with catalog-driven model loading.
101
+ - `Ask::LLM::Aliases.resolve` now aliases `deepseek-v4` → `deepseek-v4-flash`, `gpt-4o-latest` → `gpt-4o`, `gpt-4.1-latest` → `gpt-4.1`.
102
+
103
+ ### Fixed
104
+ - Model entries now include `"provider"` field in JSON files (was missing from generated data).
105
+ - User config merges properly override bundled values (was keeping old values on conflict).
106
+
107
+ ## [0.2.2] — 2026-06-25
108
+
109
+ ### Changed
110
+ - 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.
111
+ # Changelog
112
+
113
+ ## [0.1.0] — 2026-06-09
114
+
115
+ Initial release of `ask-llm-providers`, all LLM providers for the ask-rb ecosystem.
116
+
117
+ ### Added
118
+
119
+ - **OpenAI provider** — Chat Completions API with streaming, tool calls, vision, structured output, embeddings
120
+ - **Anthropic provider** — Messages API with thinking blocks, tool use, prompt caching
121
+ - **Google Gemini provider** — `generateContent` API with function calling, streaming, embeddings
122
+ - **Amazon Bedrock provider** — Converse API with tool configuration
123
+ - **Ollama provider** — Local LLM inference with chat and embeddings endpoints
124
+ - **Mistral AI provider** — OpenAI-compatible API with embeddings support
125
+ - **Cloudflare provider** — Workers AI direct endpoint and AI Gateway passthrough
126
+ - **Error mapping** — Provider-specific HTTP errors → `Ask::Error` types (rate limit, auth, context exceeded, etc.)
127
+ - **Provider registration** — All providers auto-registered with `Ask::Provider` on gem load
128
+ - **Capabilities introspection** — Each provider exposes supported capabilities
129
+ - **Shared HTTP infrastructure** — `Ask::LLM::HTTP` with Faraday connection builder and SSE streaming
130
+ - **Test suite** — 33 tests across all providers and error mapping
131
+
132
+ ## [0.1.8] — 2026-06-18
133
+
134
+ ### Added
135
+
136
+ - **OpenRouter provider** — `Ask::Providers::OpenRouter`, reads `OPENROUTER_API_KEY`,
137
+ sets `HTTP-Referer` and `X-Title` headers.
138
+
139
+ ### Fixed
140
+
141
+ - **`normalize_config` ENV resolution** — Removed broken monkey-patch that defined
142
+ `normalize_config` on `Object` instead of within the `OpenAI` class. Now resolves
143
+ `api_key` from: explicit config → subclass-specific key → `ENV` var →
144
+ `Ask::Auth.resolve` chain. All OpenAI-compatible subclasses (DeepSeek, OpenCode,
145
+ OpenCodeGo, Mimo, OpenRouter) inherit the fix.
146
+
147
+ ## [0.1.9] — 2026-06-18
148
+
149
+ ### Fixed
150
+
151
+ - **`format_messages` with Hash tool_calls** — Tool calls can arrive as Hash (keyed
152
+ by call_id) from some chat implementations, or as Array. The method now detects
153
+ both formats with `tc.is_a?(Hash) ? tc.values : tc`. Also handles `OpenStruct`
154
+ tool call objects via `.respond_to?` checks instead of assuming Hash accessors.
155
+
156
+ ## [0.2.0] — 2026-06-19
157
+
158
+ ### Fixed
159
+
160
+ - **SSE buffering across all streaming providers** — TCP fragmentation in
161
+ Faraday's `on_data` callback caused silent data loss when SSE events were
162
+ split across packets. Added persistent `@_sse_buffer` with complete-event
163
+ extraction via `Ask::LLM::SSEBuffer` module. Affected providers:
164
+ OpenAI, Anthropic, Cloudflare, Google, Ollama. All OpenAI-compatible
165
+ subclasses inherit the fix.
166
+
167
+ ### Added
168
+
169
+ - **`Ask::LLM::SSEBuffer`** — Shared module providing `init_sse_buffer` and
170
+ `each_sse_event(raw)` for SSE buffering across streaming callbacks.
171
+ - **SSE buffering tests** — 5 new tests covering fragmented data, event
172
+ boundaries, multiple events, and `[DONE]` sentinel.
173
+
174
+ ## [0.1.10] — 2026-06-18
175
+
176
+ ### Fixed
177
+
178
+ - **`chat_stream` nil response body** — Streaming requests consume the response body
179
+ via `on_data` callback, leaving `resp.body` as `nil`. `JSON.parse(nil)` raised
180
+ `TypeError: no implicit conversion of nil into String`. Now checks
181
+ `resp.body` before parsing.
@@ -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,221 @@
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
+
39
+
40
+ # Like load! but also fetches model lists from configured providers'
41
+ # list_models() APIs. Unknown models are added with minimal metadata.
42
+ def refresh!
43
+ load!
44
+ instance.fetch_from_providers
45
+ instance.register_all
46
+ true
47
+ end
48
+
49
+ private
50
+
51
+ def symbolize_keys(hash)
52
+ hash.transform_keys { |k| k.respond_to?(:to_sym) ? k.to_sym : k }
53
+ end
54
+
55
+ def instance
56
+ @instance ||= new
57
+ end
58
+ end
59
+
60
+ def initialize
61
+ @entries = []
62
+ @model_keys = Set.new
63
+ end
64
+
65
+ def clear
66
+ @entries.clear
67
+ @model_keys.clear
68
+ end
69
+
70
+ # Load bundled model JSONs from the gem's lib/ask/llm/models/ directory.
71
+ 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))
75
+ raw.each { |entry| add_entry(entry) }
76
+ end
77
+ end
78
+
79
+ # Load user-defined model overrides from ~/.ask-llm-providers/models.json.
80
+ # Silently skipped if the file doesn't exist.
81
+ def load_user_config
82
+ path = USER_CONFIG_PATH
83
+ return unless File.exist?(path)
84
+
85
+ raw = JSON.parse(File.read(path))
86
+ unless raw.is_a?(Array)
87
+ warn "Warning: #{path} should be a JSON array of model entries, got #{raw.class}"
88
+ return
89
+ end
90
+
91
+ raw.each { |entry| merge_or_add(entry) }
92
+ rescue JSON::ParserError => e
93
+ warn "Warning: Failed to parse #{path}: #{e.message}"
94
+ end
95
+
96
+ # Fetch model lists from all configured providers via their list_models() API.
97
+ # Adds unknown models with minimal metadata (no capability guessing).
98
+ def fetch_from_providers
99
+ Ask::Provider.providers.each do |slug, provider_class|
100
+ next unless provider_class.configured?(nil)
101
+
102
+ begin
103
+ provider = provider_class.new
104
+ models = provider.list_models
105
+ models.each do |m|
106
+ add_entry(m) unless @model_keys.include?([m[:id], slug.to_s])
107
+ end
108
+ rescue StandardError => e
109
+ warn "Warning: Failed to fetch models from #{slug}: #{e.message}"
110
+ end
111
+ end
112
+ end
113
+
114
+ # Register all accumulated entries into Ask::ModelCatalog.
115
+ # Also registers alias entries so models can be found by alias name.
116
+ def register_all
117
+ catalog = Ask::ModelCatalog.instance
118
+ catalog.instance_variable_set(:@models, [])
119
+
120
+ @entries.each do |entry|
121
+ info = build_model_info(entry)
122
+ catalog.register(info)
123
+ end
124
+
125
+ register_alias_entries
126
+ end
127
+
128
+ private
129
+
130
+ # For each alias (short_name → canonical_id), register a duplicate
131
+ # ModelInfo for every canonical entry whose id matches.
132
+ def register_alias_entries
133
+ Ask::LLM::Aliases.all.each do |short_name, canonical_id|
134
+ next if short_name == canonical_id
135
+
136
+ @entries.each do |entry|
137
+ next unless entry["id"] == canonical_id || entry[:id] == canonical_id
138
+
139
+ alias_entry = entry.merge("id" => short_name)
140
+ info = build_model_info(alias_entry)
141
+ Ask::ModelCatalog.instance.register(info)
142
+ end
143
+ end
144
+ end
145
+
146
+ def symbolize_keys(hash)
147
+ hash.transform_keys { |k| k.respond_to?(:to_sym) ? k.to_sym : k }
148
+ end
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
+
157
+ def add_entry(entry)
158
+ key = entry_key(entry)
159
+ return if @model_keys.include?(key)
160
+
161
+ @entries << entry
162
+ @model_keys << key
163
+ end
164
+
165
+ def merge_or_add(entry)
166
+ key = entry_key(entry)
167
+ existing = @entries.find { |e| entry_key(e) == key }
168
+
169
+ if existing
170
+ existing.merge!(entry)
171
+ else
172
+ @entries << entry
173
+ @model_keys << key
174
+ end
175
+ end
176
+
177
+ def entry_key(entry)
178
+ id = entry["id"] || entry[:id]
179
+ provider = entry["provider"] || entry[:provider]
180
+ [id, provider.to_s]
181
+ end
182
+
183
+ def build_model_info(entry)
184
+ e = entry.transform_keys(&:to_sym)
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])
195
+
196
+ Ask::ModelInfo.new(
197
+ id: e[:id],
198
+ name: e[:name] || e[:id],
199
+ provider: e[:provider],
200
+ family: e[:family],
201
+ capabilities: Array(e[:capabilities]),
202
+ context_window: e[:context_window],
203
+ max_output_tokens: e[:max_output_tokens],
204
+ modalities: modalities || { input: %w[text], output: %w[text] },
205
+ pricing: pricing,
206
+ knowledge_cutoff: knowledge_cutoff,
207
+ created_at: created_at,
208
+ metadata: (e[:metadata] || {}).merge(source: e[:metadata]&.dig("source") || "bundled")
209
+ )
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
218
+ end
219
+ end
220
+ end
221
+ end
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ask
4
+ module LLM
5
+ # Calculate LLM API costs from model pricing data.
6
+ module CostCalculator
7
+ MILLION = 1_000_000
8
+
9
+ class << self
10
+ # Calculate total cost in USD for a model invocation.
11
+ #
12
+ # @param model [Ask::ModelInfo, #pricing] the model or a pricing hash
13
+ # @param input_tokens [Integer]
14
+ # @param output_tokens [Integer]
15
+ # @param cache_read_tokens [Integer]
16
+ # @param cache_write_tokens [Integer]
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)
21
+ # @return [Float, nil] cost in USD, or nil if no pricing data
22
+ def calculate(model, input_tokens: 0, output_tokens: 0,
23
+ cache_read_tokens: 0, cache_write_tokens: 0,
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
31
+
32
+ sum = cost(input_tokens, rates[:input_per_million])
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])
36
+
37
+ if reasoning_tokens > 0
38
+ rate = rates[:reasoning_output_per_million] || rates[:output_per_million]
39
+ sum += cost(reasoning_tokens, rate)
40
+ end
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
+
48
+ sum
49
+ end
50
+
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.
72
+ #
73
+ # @return [Hash, nil]
74
+ def breakdown(model, input_tokens: 0, output_tokens: 0,
75
+ cache_read_tokens: 0, cache_write_tokens: 0,
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
81
+
82
+ rates = pricing.dig(:text_tokens, tier) or return nil
83
+
84
+ result = {
85
+ input: cost(input_tokens, rates[:input_per_million]),
86
+ output: cost(output_tokens, rates[:output_per_million]),
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])
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
99
+ end
100
+
101
+ private
102
+
103
+ def extract_pricing(model)
104
+ model.respond_to?(:pricing) ? model.pricing : model
105
+ end
106
+
107
+ def cost(tokens, rate)
108
+ return 0.0 unless rate && tokens > 0
109
+ (tokens * rate) / MILLION.to_f
110
+ end
111
+ end
112
+ end
113
+ end
114
+ end