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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +181 -0
- data/lib/ask/llm/aliases.json +11 -0
- data/lib/ask/llm/aliases.rb +62 -0
- data/lib/ask/llm/catalog.rb +221 -0
- data/lib/ask/llm/cost_calculator.rb +114 -0
- data/lib/ask/llm/http.rb +68 -12
- data/lib/ask/llm/models/anthropic.json +872 -0
- data/lib/ask/llm/models/bedrock.json +3769 -0
- data/lib/ask/llm/models/deepseek.json +340 -0
- data/lib/ask/llm/models/gemini.json +1211 -0
- data/lib/ask/llm/models/meta.json +299 -0
- data/lib/ask/llm/models/mistral.json +1303 -0
- data/lib/ask/llm/models/moonshot.json +198 -0
- data/lib/ask/llm/models/nvidia_nim.json +289 -0
- data/lib/ask/llm/models/openai.json +2521 -0
- data/lib/ask/llm/models/opencode.json +552 -0
- data/lib/ask/llm/models/perplexity.json +148 -0
- data/lib/ask/llm/models/vertex_ai.json +1347 -0
- data/lib/ask/llm/models/xai.json +326 -0
- data/lib/ask/llm/models_schema.json +74 -0
- data/lib/ask/llm/openai_compatible.rb +100 -0
- data/lib/ask/llm/provider_config.rb +65 -0
- data/lib/ask/llm/sources/models_dev.rb +154 -0
- data/lib/ask/llm/sources/openrouter.rb +176 -0
- data/lib/ask/llm/sse_buffer.rb +43 -0
- data/lib/ask/llm/version.rb +1 -1
- data/lib/ask/provider/anthropic.rb +106 -103
- data/lib/ask/provider/bedrock.rb +84 -52
- data/lib/ask/provider/cloudflare.rb +62 -40
- data/lib/ask/provider/google.rb +112 -74
- data/lib/ask/provider/mistral.rb +156 -6
- data/lib/ask/provider/ollama.rb +83 -29
- data/lib/ask/provider/openai.rb +141 -68
- data/lib/ask/provider/openai_compatible.rb +91 -0
- data/lib/ask/skills/providers.model_select/SKILL.md +164 -0
- data/lib/ask-llm-providers.rb +22 -18
- metadata +35 -7
- data/lib/ask/llm/models/openai.rb +0 -69
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: fcbf569b97e70a5c06deac427eb7cc500bb959295b2f8ce10ed20d9698cec241
|
|
4
|
+
data.tar.gz: 20a6ac87a6a4e35d84d3596d604644e2280616676399632ce2b481c3591a96f2
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|