whatmodel 0.0.2__tar.gz → 0.0.4__tar.gz

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.
@@ -0,0 +1,49 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [Unreleased]
9
+
10
+ ### Added
11
+
12
+ - `whatmodel compare <a> <b>...` puts names side by side and marks the fields that differ.
13
+ - Names with two families say which is the model and which is a source, when the name settles it (`parsed.family_roles`). Otherwise a note says the name leaves it open.
14
+ - About 50 new tokens found by measuring real names: families such as Orca Mini, SQLCoder, OLMo, InternVL, Florence and FLUX; compression formats such as FP8, NVFP4, MXFP8, bitsandbytes and `w4a16`; tunings such as `text`, `python`, `ocr`, `tts` and `asr`.
15
+ - `whatmodel -` reads names from standard input, so `ollama list | whatmodel -` works.
16
+ - `--brief` prints one row per model.
17
+ - Hosted API model IDs (`gpt-4o-2024-08-06`) and OpenRouter IDs (`anthropic/claude-3.5-sonnet:free`) are recognized, and are no longer read as Ollama names.
18
+ - Hugging Face answers are cached on disk for a day. `--no-cache` turns it off.
19
+ - Unknown tokens come with a link that opens a prefilled GitHub issue.
20
+ - `docs/index.html` runs the name reader in the browser.
21
+ - `scripts/coverage.py` measures how much of a list of real names the dictionary understands and lists the most common unknown tokens.
22
+
23
+ ### Fixed
24
+
25
+ - A name that says nothing about hosting no longer gets a "Runs locally" row. Where a model runs is shown only when the name states it or a lookup confirms it; a model installed in local Ollama is reported as local by that lookup.
26
+ - The `latest` tag Ollama would fill in is no longer shown as a Build row read from the name. It is a note below the table, since a bare name may not be an Ollama name at all.
27
+ - README links work on PyPI.
28
+
29
+ ### Changed
30
+
31
+ - Rows that a token implies rather than states now show a Source of `name (implied)`, and `implied` in JSON (was `name`).
32
+
33
+ - `whatmodel tokens` prints grid tables.
34
+
35
+ ### Added
36
+
37
+ - `whatmodel <name>...` explains Ollama names, Hugging Face repos and GGUF file names in plain English.
38
+ - Layer 1 reads the name offline: family and version, publisher, size, effective size, active parameters, tier, tuning, quantization, format, context, build stamps and hosting.
39
+ - Unknown tokens are reported as unknown, and ambiguous tokens and name formats show every reading.
40
+ - Layer 2 looks up quantization, context length, parameter count, architecture, tool, vision and thinking support, license and file size from the local Ollama API and the Hugging Face Hub API.
41
+ - `whatmodel list` explains every model installed in local Ollama.
42
+ - `whatmodel tokens` prints the token dictionary.
43
+ - Flags `--json`, `--offline`, `--ollama-url`, `--verbose` and `--version`.
44
+ - Library API: `parse()` and `explain()`.
45
+ - Token dictionary in `whatmodel/tokens.yaml`, the only place token meanings live.
46
+ - CI on Python 3.10 to 3.13 across Linux, macOS and Windows.
47
+ - Release workflow that publishes to PyPI with Trusted Publishing when a GitHub Release is published.
48
+
49
+ [Unreleased]: https://github.com/ShekharBhardwaj/whatmodel/commits/main
@@ -38,6 +38,16 @@ uv run pytest
38
38
 
39
39
  Label the pull request `tokens` so it lands under "New tokens" in the release notes.
40
40
 
41
+ ## Finding tokens worth adding
42
+
43
+ `scripts/coverage.py` reads a file with one model name per line, runs the offline name reader on each, and lists the tokens it could not explain, most common first:
44
+
45
+ ```bash
46
+ uv run python scripts/coverage.py names.txt --top 50
47
+ ```
48
+
49
+ Only add a token when you know what it means. A token left as unknown is correct; a guessed meaning is a bug.
50
+
41
51
  ## Entry fields
42
52
 
43
53
  | Field | Required | Meaning |
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: whatmodel
3
- Version: 0.0.2
3
+ Version: 0.0.4
4
4
  Summary: Explain LLM model names in plain English.
5
5
  Project-URL: Homepage, https://github.com/ShekharBhardwaj/whatmodel
6
6
  Project-URL: Repository, https://github.com/ShekharBhardwaj/whatmodel
@@ -71,7 +71,7 @@ gemma4:e2b-it-qat
71
71
  ├──────────────┼────────────────────────────────────┼──────────────┤
72
72
  │ Compression │ quantization-aware trained │ name │
73
73
  ├──────────────┼────────────────────────────────────┼──────────────┤
74
- │ Runs │ locally │ name │
74
+ │ Runs │ locally, installed in Ollama here │ local Ollama │
75
75
  ├──────────────┼────────────────────────────────────┼──────────────┤
76
76
  │ Parameters │ 4.6B │ local Ollama │
77
77
  ├──────────────┼────────────────────────────────────┼──────────────┤
@@ -114,8 +114,6 @@ bartowski/Qwen_Qwen3-30B-A3B-GGUF
114
114
  ├──────────────┼────────────────────────────────────────────┼──────────────┤
115
115
  │ Format │ GGUF (for llama.cpp, Ollama and LM Studio) │ name │
116
116
  ├──────────────┼────────────────────────────────────────────┼──────────────┤
117
- │ Runs │ locally │ name │
118
- ├──────────────┼────────────────────────────────────────────┼──────────────┤
119
117
  │ Parameters │ 30.5B │ Hugging Face │
120
118
  ├──────────────┼────────────────────────────────────────────┼──────────────┤
121
119
  │ Context │ 32k tokens │ Hugging Face │
@@ -146,8 +144,6 @@ Llama-3-8B-Instruct.Q4_K_M.gguf
146
144
  │ Compression │ 4-bit (Q4_K_M, the balanced variant) │ name │
147
145
  ├─────────────┼────────────────────────────────────────────┼────────┤
148
146
  │ Format │ GGUF (for llama.cpp, Ollama and LM Studio) │ name │
149
- ├─────────────┼────────────────────────────────────────────┼────────┤
150
- │ Runs │ locally │ name │
151
147
  └─────────────┴────────────────────────────────────────────┴────────┘
152
148
  ```
153
149
 
@@ -158,6 +154,8 @@ A file name has nothing to look up, so every row comes from the name.
158
154
  ```text
159
155
  whatmodel <name> explain one name
160
156
  whatmodel <name> <name>... explain several
157
+ whatmodel - read names from standard input, one per line
158
+ whatmodel compare <a> <b> put names side by side and mark what differs
161
159
  whatmodel list explain every model installed in local Ollama
162
160
  whatmodel tokens print the token dictionary
163
161
  ```
@@ -168,6 +166,8 @@ whatmodel tokens print the token dictionary
168
166
  | `--offline` | Read the name only and make no network requests. |
169
167
  | `--ollama-url URL` | Where Ollama listens. Defaults to `http://localhost:11434`, or `OLLAMA_HOST` if set. |
170
168
  | `--verbose`, `-v` | Add a column with a one-sentence explanation of each row. |
169
+ | `--brief`, `-b` | Print one row per model instead of a table each. |
170
+ | `--no-cache` | Do not reuse or save Hugging Face answers on disk. |
171
171
  | `--version` | Print the version. |
172
172
 
173
173
  Accepted name formats:
@@ -175,12 +175,44 @@ Accepted name formats:
175
175
  - Ollama: `[host/][namespace/]model[:tag]`, for example `qwen3.5:4b` or `hf.co/bartowski/Qwen_Qwen3-30B-A3B-GGUF:Q4_K_M`
176
176
  - Hugging Face: `org/repo`, or a pasted `https://huggingface.co/org/repo` link
177
177
  - File names: `Llama-3-8B-Instruct.Q4_K_M.gguf`, with or without a folder in front
178
+ - Hosted API model IDs: `gpt-4o-2024-08-06` or `claude-3-5-sonnet-20241022`. A name is read this way only when every family in it is offered solely as a hosted service.
179
+ - OpenRouter IDs: `anthropic/claude-3.5-sonnet:free`, recognized by an OpenRouter-only tag such as `free` or `nitro`
180
+
181
+ Piping works with `ollama list`, because only the first column of each line is read:
182
+
183
+ ```bash
184
+ ollama list | whatmodel - --brief
185
+ ```
186
+
187
+ A model that happens to be called `list`, `compare` or `tokens` can be explained with `whatmodel explain list`.
178
188
 
179
- A model that happens to be called `list` or `tokens` can be explained with `whatmodel explain list`.
189
+ ### Comparing names
190
+
191
+ ```console
192
+ $ whatmodel compare llama3.1:8b-instruct-q4_K_M llama3.1:8b-instruct-q5_K_M --offline
193
+
194
+ ┌─────────────┬──────────────────────────┬───────────────────────────┬─────────┐
195
+ │ │ llama3.1:8b-instruct-q4_ │ llama3.1:8b-instruct-q5_K │ │
196
+ │ Field │ K_M │ _M │ Same? │
197
+ ├─────────────┼──────────────────────────┼───────────────────────────┼─────────┤
198
+ │ Family │ Llama 3.1 (Meta) │ Llama 3.1 (Meta) │ same │
199
+ ├─────────────┼──────────────────────────┼───────────────────────────┼─────────┤
200
+ │ Size │ 8B parameters │ 8B parameters │ same │
201
+ ├─────────────┼──────────────────────────┼───────────────────────────┼─────────┤
202
+ │ Tuning │ instruction tuned (ready │ instruction tuned (ready │ same │
203
+ │ │ for chat) │ for chat) │ │
204
+ ├─────────────┼──────────────────────────┼───────────────────────────┼─────────┤
205
+ │ Compression │ 4-bit (Q4_K_M, the │ 5-bit (Q5_K_M, the │ differs │
206
+ │ │ balanced variant) │ balanced variant) │ │
207
+ └─────────────┴──────────────────────────┴───────────────────────────┴─────────┘
208
+ Every row is read from the names. Nothing was looked up.
209
+ ```
210
+
211
+ Without `--offline` the looked-up facts are compared too, below a divider. `--json` gives the same rows as data.
180
212
 
181
213
  ### Unknown and ambiguous tokens
182
214
 
183
- whatmodel never guesses. A token that is not in its dictionary is reported as unknown. A token with two possible meanings is shown with both:
215
+ whatmodel never guesses. A token that is not in its dictionary is reported as unknown, with a link that opens a prefilled GitHub issue so you can report what it means. A token with two possible meanings is shown with both:
184
216
 
185
217
  ```console
186
218
  $ whatmodel qwen-max-0725 --offline
@@ -193,17 +225,43 @@ qwen-max-0725
193
225
  ├───────────┼───────────────────────────────────────┼────────┤
194
226
  │ Tier │ max tier (the most capable option) │ name │
195
227
  ├───────────┼───────────────────────────────────────┼────────┤
196
- │ Build │ latest (the default build) │ name │
197
- ├───────────┼───────────────────────────────────────┼────────┤
198
- │ Runs │ locally │ name │
199
- ├───────────┼───────────────────────────────────────┼────────┤
200
228
  │ Ambiguous │ 0725: dated 07-25 (month-day) [build] │ name │
201
229
  │ │ or released 2025-07 [build] │ │
202
230
  └───────────┴───────────────────────────────────────┴────────┘
231
+ No tag in the name. Pulled with Ollama, it would mean latest.
203
232
  ```
204
233
 
234
+ The same rule covers what a name leaves out. A name alone cannot show where a model runs or which build you would get, so whatmodel adds a Runs row only when the name says so (a `cloud` tag, or a family that is only offered as a hosted service) or a lookup confirms it.
235
+
205
236
  The same applies to a name such as `org/model` with no tag, which fits both a Hugging Face repo and an Ollama model. Both readings are named unless a lookup settles which one it is.
206
237
 
238
+ ### Names with two families
239
+
240
+ Fine-tunes and distillations often name two model families. whatmodel says which one is the model only when the name settles it:
241
+
242
+ ```console
243
+ $ whatmodel deepseek-ai/DeepSeek-R1-Distill-Qwen-7B --offline
244
+
245
+ deepseek-ai/DeepSeek-R1-Distill-Qwen-7B
246
+ ┌───────────┬──────────────────────────────────┬────────┐
247
+ │ Field │ Value │ Source │
248
+ ├───────────┼──────────────────────────────────┼────────┤
249
+ │ Publisher │ DeepSeek │ name │
250
+ ├───────────┼──────────────────────────────────┼────────┤
251
+ │ Family │ DeepSeek R1 (DeepSeek), a source │ name │
252
+ │ │ Qwen (Alibaba), the model │ │
253
+ ├───────────┼──────────────────────────────────┼────────┤
254
+ │ Size │ 7B parameters │ name │
255
+ ├───────────┼──────────────────────────────────┼────────┤
256
+ │ Tuning │ distilled │ name │
257
+ └───────────┴──────────────────────────────────┴────────┘
258
+ Qwen is read as the model and DeepSeek R1 as a source: the family right after
259
+ 'Distill' is the one that was trained.
260
+ Read as a Hugging Face repo or an Ollama model (tag latest).
261
+ ```
262
+
263
+ Two rules are used. The family right after `Distill` is the one that was trained. A family that is only offered as a hosted service, such as Claude, has no weights to share, so it is read as a source. When neither rule applies, the output says the name leaves it open.
264
+
207
265
  ### Where the facts come from
208
266
 
209
267
  The lookup step tries two sources in order and merges them:
@@ -211,7 +269,11 @@ The lookup step tries two sources in order and merges them:
211
269
  1. The local Ollama API (`/api/show` and `/api/tags`), for models you have installed.
212
270
  2. The Hugging Face Hub API, for names that contain `org/repo`.
213
271
 
214
- Timeouts are short. A source that is offline, slow, or does not know the model is skipped, and the name is still explained. In JSON every item carries a `source` of `name`, `ollama` or `huggingface`, and `lookups` reports what happened with each source.
272
+ Timeouts are short. A source that is offline, slow, or does not know the model is skipped, and the name is still explained. In JSON every item carries a `source` of `name`, `implied`, `ollama` or `huggingface`, and `lookups` reports what happened with each source.
273
+
274
+ Hugging Face answers are saved on disk for a day, so repeated lookups are instant and still work without a network connection. A note under the table says when a saved copy was used. `--no-cache` turns this off, and `WHATMODEL_CACHE_DIR` moves the folder. Ollama answers are never cached. As a library, `explain()` caches only when you pass `cache_dir`.
275
+
276
+ A Source of `name (implied)` marks a row that no token states outright but another token settles. `A3B` (3B active parameters) implies a mixture-of-experts design, for example. Run with `-v` to see the reason.
215
277
 
216
278
  ## Library
217
279
 
@@ -221,6 +283,7 @@ from whatmodel import parse, explain
221
283
  parsed = parse("qwen3.5:4b") # the name only, no network
222
284
  parsed.family, parsed.version, parsed.size # ('qwen', '3.5', '4B')
223
285
  parsed.unknown # tokens the dictionary does not know
286
+ parsed.family_roles # {'qwen': 'model', 'claude': 'source'} when a name has two families
224
287
 
225
288
  info = explain("qwen3.5:4b") # the name plus lookups
226
289
  info.facts # looked-up facts, each with its source
@@ -229,6 +292,14 @@ info.to_dict() # the same data --json prints
229
292
 
230
293
  `explain(name, offline=True)` makes no requests at all.
231
294
 
295
+ ## In the browser
296
+
297
+ [docs/index.html](https://github.com/ShekharBhardwaj/whatmodel/blob/main/docs/index.html) is a single page that runs the same name reader in the browser, with no server and nothing sent anywhere. Served with GitHub Pages it lives at <https://shekharbhardwaj.github.io/whatmodel/>. It reads names only and makes no lookups.
298
+
299
+ ## Coverage
300
+
301
+ Measured on 5,918 popular Ollama and Hugging Face names on 2026-11-12, whatmodel read 75% of names with no unknown token, and knew 94% of all tokens. New model families appear every week, so the rest are reported as unknown until someone adds them. `scripts/coverage.py` repeats the measurement on any list of names.
302
+
232
303
  ## What the name cannot tell you
233
304
 
234
305
  A model name is a label chosen by whoever published it. Even a fully understood name leaves these open, which is what the lookup step is for:
@@ -39,7 +39,7 @@ gemma4:e2b-it-qat
39
39
  ├──────────────┼────────────────────────────────────┼──────────────┤
40
40
  │ Compression │ quantization-aware trained │ name │
41
41
  ├──────────────┼────────────────────────────────────┼──────────────┤
42
- │ Runs │ locally │ name │
42
+ │ Runs │ locally, installed in Ollama here │ local Ollama │
43
43
  ├──────────────┼────────────────────────────────────┼──────────────┤
44
44
  │ Parameters │ 4.6B │ local Ollama │
45
45
  ├──────────────┼────────────────────────────────────┼──────────────┤
@@ -82,8 +82,6 @@ bartowski/Qwen_Qwen3-30B-A3B-GGUF
82
82
  ├──────────────┼────────────────────────────────────────────┼──────────────┤
83
83
  │ Format │ GGUF (for llama.cpp, Ollama and LM Studio) │ name │
84
84
  ├──────────────┼────────────────────────────────────────────┼──────────────┤
85
- │ Runs │ locally │ name │
86
- ├──────────────┼────────────────────────────────────────────┼──────────────┤
87
85
  │ Parameters │ 30.5B │ Hugging Face │
88
86
  ├──────────────┼────────────────────────────────────────────┼──────────────┤
89
87
  │ Context │ 32k tokens │ Hugging Face │
@@ -114,8 +112,6 @@ Llama-3-8B-Instruct.Q4_K_M.gguf
114
112
  │ Compression │ 4-bit (Q4_K_M, the balanced variant) │ name │
115
113
  ├─────────────┼────────────────────────────────────────────┼────────┤
116
114
  │ Format │ GGUF (for llama.cpp, Ollama and LM Studio) │ name │
117
- ├─────────────┼────────────────────────────────────────────┼────────┤
118
- │ Runs │ locally │ name │
119
115
  └─────────────┴────────────────────────────────────────────┴────────┘
120
116
  ```
121
117
 
@@ -126,6 +122,8 @@ A file name has nothing to look up, so every row comes from the name.
126
122
  ```text
127
123
  whatmodel <name> explain one name
128
124
  whatmodel <name> <name>... explain several
125
+ whatmodel - read names from standard input, one per line
126
+ whatmodel compare <a> <b> put names side by side and mark what differs
129
127
  whatmodel list explain every model installed in local Ollama
130
128
  whatmodel tokens print the token dictionary
131
129
  ```
@@ -136,6 +134,8 @@ whatmodel tokens print the token dictionary
136
134
  | `--offline` | Read the name only and make no network requests. |
137
135
  | `--ollama-url URL` | Where Ollama listens. Defaults to `http://localhost:11434`, or `OLLAMA_HOST` if set. |
138
136
  | `--verbose`, `-v` | Add a column with a one-sentence explanation of each row. |
137
+ | `--brief`, `-b` | Print one row per model instead of a table each. |
138
+ | `--no-cache` | Do not reuse or save Hugging Face answers on disk. |
139
139
  | `--version` | Print the version. |
140
140
 
141
141
  Accepted name formats:
@@ -143,12 +143,44 @@ Accepted name formats:
143
143
  - Ollama: `[host/][namespace/]model[:tag]`, for example `qwen3.5:4b` or `hf.co/bartowski/Qwen_Qwen3-30B-A3B-GGUF:Q4_K_M`
144
144
  - Hugging Face: `org/repo`, or a pasted `https://huggingface.co/org/repo` link
145
145
  - File names: `Llama-3-8B-Instruct.Q4_K_M.gguf`, with or without a folder in front
146
+ - Hosted API model IDs: `gpt-4o-2024-08-06` or `claude-3-5-sonnet-20241022`. A name is read this way only when every family in it is offered solely as a hosted service.
147
+ - OpenRouter IDs: `anthropic/claude-3.5-sonnet:free`, recognized by an OpenRouter-only tag such as `free` or `nitro`
148
+
149
+ Piping works with `ollama list`, because only the first column of each line is read:
150
+
151
+ ```bash
152
+ ollama list | whatmodel - --brief
153
+ ```
154
+
155
+ A model that happens to be called `list`, `compare` or `tokens` can be explained with `whatmodel explain list`.
146
156
 
147
- A model that happens to be called `list` or `tokens` can be explained with `whatmodel explain list`.
157
+ ### Comparing names
158
+
159
+ ```console
160
+ $ whatmodel compare llama3.1:8b-instruct-q4_K_M llama3.1:8b-instruct-q5_K_M --offline
161
+
162
+ ┌─────────────┬──────────────────────────┬───────────────────────────┬─────────┐
163
+ │ │ llama3.1:8b-instruct-q4_ │ llama3.1:8b-instruct-q5_K │ │
164
+ │ Field │ K_M │ _M │ Same? │
165
+ ├─────────────┼──────────────────────────┼───────────────────────────┼─────────┤
166
+ │ Family │ Llama 3.1 (Meta) │ Llama 3.1 (Meta) │ same │
167
+ ├─────────────┼──────────────────────────┼───────────────────────────┼─────────┤
168
+ │ Size │ 8B parameters │ 8B parameters │ same │
169
+ ├─────────────┼──────────────────────────┼───────────────────────────┼─────────┤
170
+ │ Tuning │ instruction tuned (ready │ instruction tuned (ready │ same │
171
+ │ │ for chat) │ for chat) │ │
172
+ ├─────────────┼──────────────────────────┼───────────────────────────┼─────────┤
173
+ │ Compression │ 4-bit (Q4_K_M, the │ 5-bit (Q5_K_M, the │ differs │
174
+ │ │ balanced variant) │ balanced variant) │ │
175
+ └─────────────┴──────────────────────────┴───────────────────────────┴─────────┘
176
+ Every row is read from the names. Nothing was looked up.
177
+ ```
178
+
179
+ Without `--offline` the looked-up facts are compared too, below a divider. `--json` gives the same rows as data.
148
180
 
149
181
  ### Unknown and ambiguous tokens
150
182
 
151
- whatmodel never guesses. A token that is not in its dictionary is reported as unknown. A token with two possible meanings is shown with both:
183
+ whatmodel never guesses. A token that is not in its dictionary is reported as unknown, with a link that opens a prefilled GitHub issue so you can report what it means. A token with two possible meanings is shown with both:
152
184
 
153
185
  ```console
154
186
  $ whatmodel qwen-max-0725 --offline
@@ -161,17 +193,43 @@ qwen-max-0725
161
193
  ├───────────┼───────────────────────────────────────┼────────┤
162
194
  │ Tier │ max tier (the most capable option) │ name │
163
195
  ├───────────┼───────────────────────────────────────┼────────┤
164
- │ Build │ latest (the default build) │ name │
165
- ├───────────┼───────────────────────────────────────┼────────┤
166
- │ Runs │ locally │ name │
167
- ├───────────┼───────────────────────────────────────┼────────┤
168
196
  │ Ambiguous │ 0725: dated 07-25 (month-day) [build] │ name │
169
197
  │ │ or released 2025-07 [build] │ │
170
198
  └───────────┴───────────────────────────────────────┴────────┘
199
+ No tag in the name. Pulled with Ollama, it would mean latest.
171
200
  ```
172
201
 
202
+ The same rule covers what a name leaves out. A name alone cannot show where a model runs or which build you would get, so whatmodel adds a Runs row only when the name says so (a `cloud` tag, or a family that is only offered as a hosted service) or a lookup confirms it.
203
+
173
204
  The same applies to a name such as `org/model` with no tag, which fits both a Hugging Face repo and an Ollama model. Both readings are named unless a lookup settles which one it is.
174
205
 
206
+ ### Names with two families
207
+
208
+ Fine-tunes and distillations often name two model families. whatmodel says which one is the model only when the name settles it:
209
+
210
+ ```console
211
+ $ whatmodel deepseek-ai/DeepSeek-R1-Distill-Qwen-7B --offline
212
+
213
+ deepseek-ai/DeepSeek-R1-Distill-Qwen-7B
214
+ ┌───────────┬──────────────────────────────────┬────────┐
215
+ │ Field │ Value │ Source │
216
+ ├───────────┼──────────────────────────────────┼────────┤
217
+ │ Publisher │ DeepSeek │ name │
218
+ ├───────────┼──────────────────────────────────┼────────┤
219
+ │ Family │ DeepSeek R1 (DeepSeek), a source │ name │
220
+ │ │ Qwen (Alibaba), the model │ │
221
+ ├───────────┼──────────────────────────────────┼────────┤
222
+ │ Size │ 7B parameters │ name │
223
+ ├───────────┼──────────────────────────────────┼────────┤
224
+ │ Tuning │ distilled │ name │
225
+ └───────────┴──────────────────────────────────┴────────┘
226
+ Qwen is read as the model and DeepSeek R1 as a source: the family right after
227
+ 'Distill' is the one that was trained.
228
+ Read as a Hugging Face repo or an Ollama model (tag latest).
229
+ ```
230
+
231
+ Two rules are used. The family right after `Distill` is the one that was trained. A family that is only offered as a hosted service, such as Claude, has no weights to share, so it is read as a source. When neither rule applies, the output says the name leaves it open.
232
+
175
233
  ### Where the facts come from
176
234
 
177
235
  The lookup step tries two sources in order and merges them:
@@ -179,7 +237,11 @@ The lookup step tries two sources in order and merges them:
179
237
  1. The local Ollama API (`/api/show` and `/api/tags`), for models you have installed.
180
238
  2. The Hugging Face Hub API, for names that contain `org/repo`.
181
239
 
182
- Timeouts are short. A source that is offline, slow, or does not know the model is skipped, and the name is still explained. In JSON every item carries a `source` of `name`, `ollama` or `huggingface`, and `lookups` reports what happened with each source.
240
+ Timeouts are short. A source that is offline, slow, or does not know the model is skipped, and the name is still explained. In JSON every item carries a `source` of `name`, `implied`, `ollama` or `huggingface`, and `lookups` reports what happened with each source.
241
+
242
+ Hugging Face answers are saved on disk for a day, so repeated lookups are instant and still work without a network connection. A note under the table says when a saved copy was used. `--no-cache` turns this off, and `WHATMODEL_CACHE_DIR` moves the folder. Ollama answers are never cached. As a library, `explain()` caches only when you pass `cache_dir`.
243
+
244
+ A Source of `name (implied)` marks a row that no token states outright but another token settles. `A3B` (3B active parameters) implies a mixture-of-experts design, for example. Run with `-v` to see the reason.
183
245
 
184
246
  ## Library
185
247
 
@@ -189,6 +251,7 @@ from whatmodel import parse, explain
189
251
  parsed = parse("qwen3.5:4b") # the name only, no network
190
252
  parsed.family, parsed.version, parsed.size # ('qwen', '3.5', '4B')
191
253
  parsed.unknown # tokens the dictionary does not know
254
+ parsed.family_roles # {'qwen': 'model', 'claude': 'source'} when a name has two families
192
255
 
193
256
  info = explain("qwen3.5:4b") # the name plus lookups
194
257
  info.facts # looked-up facts, each with its source
@@ -197,6 +260,14 @@ info.to_dict() # the same data --json prints
197
260
 
198
261
  `explain(name, offline=True)` makes no requests at all.
199
262
 
263
+ ## In the browser
264
+
265
+ [docs/index.html](https://github.com/ShekharBhardwaj/whatmodel/blob/main/docs/index.html) is a single page that runs the same name reader in the browser, with no server and nothing sent anywhere. Served with GitHub Pages it lives at <https://shekharbhardwaj.github.io/whatmodel/>. It reads names only and makes no lookups.
266
+
267
+ ## Coverage
268
+
269
+ Measured on 5,918 popular Ollama and Hugging Face names on 2026-11-12, whatmodel read 75% of names with no unknown token, and knew 94% of all tokens. New model families appear every week, so the rest are reported as unknown until someone adds them. `scripts/coverage.py` repeats the measurement on any list of names.
270
+
200
271
  ## What the name cannot tell you
201
272
 
202
273
  A model name is a label chosen by whoever published it. Even a fully understood name leaves these open, which is what the lookup step is for:
@@ -26,3 +26,11 @@ def _no_network(monkeypatch: pytest.MonkeyPatch) -> None:
26
26
  monkeypatch.setattr(socket.socket, "connect", blocked)
27
27
  monkeypatch.setattr(socket.socket, "connect_ex", blocked)
28
28
  monkeypatch.setattr(socket, "getaddrinfo", blocked)
29
+
30
+
31
+ @pytest.fixture(autouse=True)
32
+ def _private_cache(
33
+ monkeypatch: pytest.MonkeyPatch, tmp_path_factory: pytest.TempPathFactory
34
+ ) -> None:
35
+ """Point the lookup cache at an empty folder, never at the developer's real one."""
36
+ monkeypatch.setenv("WHATMODEL_CACHE_DIR", str(tmp_path_factory.mktemp("cache")))
@@ -0,0 +1,96 @@
1
+ """The on-disk lookup cache."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ import httpx
8
+ import pytest
9
+
10
+ from whatmodel import cache, explain
11
+
12
+ BODY = {"id": "org/repo", "cardData": {"license": "mit"}}
13
+
14
+
15
+ def test_round_trip(tmp_path: Path) -> None:
16
+ assert cache.read(tmp_path, "org/repo") is None
17
+ cache.write(tmp_path, "org/repo", BODY)
18
+ assert cache.read(tmp_path, "org/repo") == BODY
19
+ assert cache.read(tmp_path, "Org/Repo") == BODY # repo names ignore case
20
+ assert cache.read(tmp_path, "org/other") is None
21
+
22
+
23
+ def test_entries_expire(tmp_path: Path) -> None:
24
+ cache.write(tmp_path, "org/repo", BODY, now=1000.0)
25
+ assert cache.read(tmp_path, "org/repo", now=1000.0 + cache.MAX_AGE - 1) == BODY
26
+ assert cache.read(tmp_path, "org/repo", now=1000.0 + cache.MAX_AGE + 1) is None
27
+ assert cache.read(tmp_path, "org/repo", now=999.0) is None # saved in the future
28
+
29
+
30
+ def test_broken_files_are_ignored(tmp_path: Path) -> None:
31
+ cache.write(tmp_path, "org/repo", BODY)
32
+ (file,) = tmp_path.glob("*.json")
33
+ file.write_text("not json", encoding="utf-8")
34
+ assert cache.read(tmp_path, "org/repo") is None
35
+
36
+
37
+ def test_unwritable_cache_is_ignored(tmp_path: Path) -> None:
38
+ blocker = tmp_path / "file"
39
+ blocker.write_text("", encoding="utf-8")
40
+ cache.write(blocker / "sub", "org/repo", BODY) # must not raise
41
+ assert cache.read(blocker / "sub", "org/repo") is None
42
+
43
+
44
+ def test_clear(tmp_path: Path) -> None:
45
+ cache.write(tmp_path, "a/b", BODY)
46
+ cache.write(tmp_path, "c/d", BODY)
47
+ assert cache.clear(tmp_path) == 2
48
+ assert cache.read(tmp_path, "a/b") is None
49
+ assert cache.clear(tmp_path / "missing") == 0
50
+
51
+
52
+ def test_default_dir_follows_the_environment(monkeypatch: pytest.MonkeyPatch) -> None:
53
+ monkeypatch.setenv(cache.ENV_VAR, "/somewhere/else")
54
+ assert cache.default_cache_dir() == Path("/somewhere/else")
55
+ monkeypatch.delenv(cache.ENV_VAR)
56
+ assert cache.default_cache_dir().name.lower() in {"whatmodel", "cache"}
57
+
58
+
59
+ def _client(calls: list[str]) -> httpx.Client:
60
+ def handler(request: httpx.Request) -> httpx.Response:
61
+ calls.append(request.url.host)
62
+ if request.url.host == "huggingface.co":
63
+ return httpx.Response(200, json=BODY)
64
+ return httpx.Response(404, json={})
65
+
66
+ return httpx.Client(transport=httpx.MockTransport(handler))
67
+
68
+
69
+ def test_second_lookup_is_served_from_the_cache(tmp_path: Path) -> None:
70
+ calls: list[str] = []
71
+ first = explain("org/repo", client=_client(calls), cache_dir=tmp_path)
72
+ assert first.cached == ()
73
+ assert calls.count("huggingface.co") == 1
74
+
75
+ second = explain("org/repo", client=_client(calls), cache_dir=tmp_path)
76
+ assert second.cached == ("huggingface",)
77
+ assert calls.count("huggingface.co") == 1 # no second request
78
+ assert second.facts == first.facts
79
+ assert second.lookups["huggingface"] == "ok"
80
+
81
+
82
+ def test_nothing_touches_disk_without_a_cache_dir(tmp_path: Path) -> None:
83
+ calls: list[str] = []
84
+ explain("org/repo", client=_client(calls))
85
+ explain("org/repo", client=_client(calls))
86
+ assert calls.count("huggingface.co") == 2
87
+ assert list(tmp_path.iterdir()) == []
88
+
89
+
90
+ def test_failed_lookups_are_not_cached(tmp_path: Path) -> None:
91
+ def handler(request: httpx.Request) -> httpx.Response:
92
+ return httpx.Response(404, json={})
93
+
94
+ client = httpx.Client(transport=httpx.MockTransport(handler))
95
+ explain("org/repo", client=client, cache_dir=tmp_path)
96
+ assert list(tmp_path.glob("*.json")) == []