pi-ollama-cloud 0.11.0 → 0.12.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.
package/CHANGELOG.md CHANGED
@@ -2,7 +2,14 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
- ## [Unreleased]
5
+ ## [0.12.0] - 2026-09-11
6
+
7
+ - Source per-model thinking levels from models.dev instead of hardcoded maps. `scripts/generate-reasoning.ts` fetches the `ollama-cloud` provider's `reasoning_options` into `reasoning.generated.ts`, and `thinking-levels.ts` maps each model's effort values onto Pi's levels (toggle-only models become a binary on/off map; models with no models.dev entry fall back to `DEFAULT`). The `off` switch is handled by a small override table for models verified not to honor `reasoning_effort:"none"` (`gpt-oss:20b`, `gpt-oss:120b`, `minimax-m2.7`). Removed the now-stale per-family maps and the `docs/think-experiment.md` doc.
8
+ - `generate-models` now also refreshes `reasoning.generated.ts` (runs `generate-pricing`, `generate-reasoning`, then `generate-models`).
9
+ - Fix `generate-pricing` mis-dropping models whose pricing-page cached-input cell is `-` (no cache rate): those rows now match and their `cacheRead` equals `input`. This restored pricing for `mistral-large-3:675b`, `nemotron-3-nano:30b`, and `qwen3.5:397b`, which the earlier regex had left at zero cost.
10
+ - Refresh the model catalog: added `deepseek-v4.1-flash` (probed max output 393216).
11
+
12
+ ## [0.11.0] - 2026-09-07
6
13
 
7
14
  - Fix `/ollama-cloud-usage` and the usage status bar failing with "unexpected response shape" after the undocumented `/api/usage` endpoint flipped between a single `limits.monthly` bucket and `limits.session` plus `limits.weekly` (the shape has flip-flopped repeatedly as of 2026-09). Any bucket present (`monthly`, `session`, `weekly`) is accepted alone or in combination, and whichever are present are displayed as `5h`/`7d`/`30d` segments. Thanks @johanngyger (#56).
8
15
  - Cache `ollama_web_search` results (24h) and `ollama_web_fetch` pages (24h success / 15 min failure) on disk under the pi agent home, so repeated queries and page reads cost 0 API calls. Expired entries are pruned on write, the cache is capped at 500 entries per kind (oldest evicted beyond the cap; `PI_OLLAMA_SEARCH_MAX_ENTRIES`), a partially corrupted cache file is validated per entry and degrades to "no cache" instead of crashing tool calls, and the file is written with `0600` permissions since it stores page content and URLs that can embed credentials. Tune with `PI_OLLAMA_SEARCH_TTL_HOURS`, `PI_OLLAMA_SEARCH_FAIL_TTL_MINUTES`, `PI_OLLAMA_SEARCH_MAX_ENTRIES`, and `PI_OLLAMA_SEARCH_CACHE_PATH`.
package/README.md CHANGED
@@ -7,7 +7,7 @@ Registers Ollama Cloud as a model provider with dynamically fetched models, and
7
7
  ## Features
8
8
 
9
9
  - **Dynamic model discovery** - Fetches the full model list from `ollama.com/v1/models`, then fetches per-model details via `/api/show` to determine capabilities, context length, and tool support.
10
- - **Curated thinking levels** - Maps Pi's thinking levels to Ollama Cloud's OpenAI-compatible `reasoning_effort` values via `thinking-levels.ts`, with per-model exceptions based on API testing.
10
+ - **Data-driven thinking levels** - Maps Pi's thinking levels to Ollama Cloud's OpenAI-compatible `reasoning_effort` values via `thinking-levels.ts`, sourced from models.dev per-model reasoning options with a small override table for the models where `none` doesn't disable thinking.
11
11
  - **Baked-in model list** - A generated fallback list (`models.generated.ts`) ships with the extension so models are available on first launch without any network calls. It is only a fallback: pi refreshes the live catalog at runtime, so shipping a new release for catalog freshness is no longer needed.
12
12
  - **Automatic model refresh** - On startup, `/model` open, and `pi update --models`, pi calls the extension's `refreshModels` callback to fetch the latest models from the API and persists them through pi's own model store. No manual refresh command.
13
13
  - **`ollama_web_search` tool** - Search the web for real-time information using Ollama Cloud's `/api/web_search` endpoint. Returns titles, URLs, and content snippets.
@@ -132,7 +132,7 @@ Model metadata is derived from the `/api/show` response:
132
132
  | Field | Source |
133
133
  |---|---|
134
134
  | `reasoning` | `capabilities` includes `"thinking"` |
135
- | `thinkingLevelMap` | [`thinking-levels.ts`](thinking-levels.ts) with 5 maps (DEFAULT, GPT_OSS, QWEN3, GLM_52, NO_OFF) based on API testing |
135
+ | `thinkingLevelMap` | [`thinking-levels.ts`](thinking-levels.ts) + [`reasoning.generated.ts`](reasoning.generated.ts) (models.dev reasoning options), with an `off` override table for models that ignore `none` |
136
136
  | `input` | `["text", "image"]` if `capabilities` includes `"vision"`, else `["text"]` |
137
137
  | `contextWindow` | `model_info.*.context_length` (falls back to 128000) |
138
138
  | `maxTokens` | Probed per-model limits from [`limits.generated.ts`](limits.generated.ts), generated by `scripts/generate-limits.ts` (requires `OLLAMA_API_KEY`). Models without a probed limit fall back to 32768. |
@@ -146,17 +146,11 @@ Cache pricing is informational only: the `/pricing` page lists a "Cached input"
146
146
 
147
147
  ### Thinking level mapping
148
148
 
149
- Pi's thinking levels are mapped to Ollama Cloud's OpenAI-compatible `reasoning_effort` parameter in [`thinking-levels.ts`](thinking-levels.ts). The API accepts `none`, `low`, `medium`, `high`, and `max`. Effects of `max` over `high` vary by model and prompt difficulty - see [`docs/think-experiment.md`](docs/think-experiment.md) for details.
149
+ Pi's thinking levels are mapped to Ollama Cloud's OpenAI-compatible `reasoning_effort` parameter in [`thinking-levels.ts`](thinking-levels.ts). The API accepts `none`, `low`, `medium`, `high`, `xhigh`, and `max`. Effects of `max` over `high` vary by model and prompt difficulty.
150
150
 
151
- | Map | Models | Levels exposed | Notes |
152
- |---|---|---|---|
153
- | `DEFAULT` | Most thinking models | off, low, medium, high, xhigh | `minimal` hidden (duplicate of low) |
154
- | `GPT_OSS` | `gpt-oss*` | low, medium, high | Can't disable thinking, no off or xhigh |
155
- | `QWEN3` | `qwen3*` (except `qwen3-vl*`) | off, medium | Binary-only (think/nothink), no gradation |
156
- | `GLM_52` | `glm-5.2` | off, high, xhigh | GLM supports disabled thinking; Ollama's model page confirms `high` and `max` reasoning efforts |
157
- | `NO_OFF` | `qwen3-vl*`, `kimi-k2-thinking`, `minimax*` | low, medium, high, xhigh | "none" doesn't disable thinking on these models |
151
+ Per-model support is sourced from models.dev: [`scripts/generate-reasoning.ts`](scripts/generate-reasoning.ts) fetches the `ollama-cloud` provider's `reasoning_options` into `reasoning.generated.ts`, and `resolve()` maps each model's effort values onto Pi's levels. Models with `effort` values expose those grades; `toggle`-only models expose a single on/off level. Models with no models.dev entry fall back to `DEFAULT`.
158
152
 
159
- See [docs/think-experiment.md](docs/think-experiment.md) for the testing methodology and results.
153
+ Because the API reports only a boolean `thinking` capability and models.dev does not reliably encode the `none` behavior, the `off` switch is handled via a small override table in `thinking-levels.ts`: it defaults to enabled, and is hidden only for models verified (by live probing) not to honor `reasoning_effort:"none"` - currently `gpt-oss:20b`, `gpt-oss:120b`, and `minimax-m2.7`. The per-model metadata gaps behind the models.dev sourcing are tracked upstream in [ollama/ollama#18385](https://github.com/ollama/ollama/issues/18385).
160
154
 
161
155
  ## Tools
162
156
 
@@ -1,10 +1,11 @@
1
1
  // Auto-generated by scripts/generate-limits.ts
2
2
  // Do not edit manually.
3
- // Probed models: 19 (0 failed)
3
+ // Entries: 20
4
4
 
5
5
  export const MODEL_MAX_OUTPUT_TOKENS: Record<string, number> = {
6
6
  "deepseek-v4-flash:0731": 65536,
7
7
  "deepseek-v4-pro:0813": 65536,
8
+ "deepseek-v4.1-flash": 393216,
8
9
  "gemma4:31b": 262144,
9
10
  "glm-5.1": 131072,
10
11
  "glm-5.2": 131072,
@@ -1,7 +1,7 @@
1
1
  // Auto-generated by scripts/generate-models.ts
2
2
  // Do not edit manually.
3
- // Generated: 2026-09-03T10:12:02.243Z
4
- // Model count: 19
3
+ // Generated: 2026-09-12T02:41:57.025Z
4
+ // Model count: 20
5
5
 
6
6
  import type { ProviderModelConfig } from "@earendil-works/pi-coding-agent";
7
7
 
@@ -39,8 +39,8 @@ export const GENERATED_MODELS: ProviderModelConfig[] = [
39
39
  reasoning: true,
40
40
  thinkingLevelMap: {
41
41
  high: "high",
42
- low: "low",
43
- medium: "medium",
42
+ low: null,
43
+ medium: null,
44
44
  minimal: null,
45
45
  off: "none",
46
46
  xhigh: "max",
@@ -77,10 +77,50 @@ export const GENERATED_MODELS: ProviderModelConfig[] = [
77
77
  input: ["text"],
78
78
  maxTokens: 65536,
79
79
  reasoning: true,
80
+ thinkingLevelMap: {
81
+ high: "high",
82
+ low: null,
83
+ medium: null,
84
+ minimal: null,
85
+ off: "none",
86
+ xhigh: "max",
87
+ },
88
+ },
89
+ {
90
+ id: "deepseek-v4.1-flash",
91
+ name: "deepseek-v4.1-flash",
92
+ compat: {
93
+ maxTokensField: "max_tokens",
94
+ openRouterRouting: {},
95
+ requiresAssistantAfterToolResult: false,
96
+ requiresReasoningContentOnAssistantMessages: false,
97
+ requiresThinkingAsText: false,
98
+ requiresToolResultName: false,
99
+ sendSessionAffinityHeaders: false,
100
+ supportsDeveloperRole: false,
101
+ supportsLongCacheRetention: false,
102
+ supportsReasoningEffort: true,
103
+ supportsStore: false,
104
+ supportsStrictMode: false,
105
+ supportsUsageInStreaming: true,
106
+ thinkingFormat: "openai",
107
+ vercelGatewayRouting: {},
108
+ zaiToolStream: false,
109
+ },
110
+ contextWindow: 1048576,
111
+ cost: {
112
+ cacheRead: 0.006,
113
+ cacheWrite: 0,
114
+ input: 0.3,
115
+ output: 1.2,
116
+ },
117
+ input: ["text", "image"],
118
+ maxTokens: 393216,
119
+ reasoning: true,
80
120
  thinkingLevelMap: {
81
121
  high: "high",
82
122
  low: "low",
83
- medium: "medium",
123
+ medium: null,
84
124
  minimal: null,
85
125
  off: "none",
86
126
  xhigh: "max",
@@ -118,12 +158,12 @@ export const GENERATED_MODELS: ProviderModelConfig[] = [
118
158
  maxTokens: 262144,
119
159
  reasoning: true,
120
160
  thinkingLevelMap: {
121
- high: "high",
122
- low: "low",
161
+ high: null,
162
+ low: null,
123
163
  medium: "medium",
124
164
  minimal: null,
125
165
  off: "none",
126
- xhigh: "max",
166
+ xhigh: null,
127
167
  },
128
168
  },
129
169
  {
@@ -158,12 +198,12 @@ export const GENERATED_MODELS: ProviderModelConfig[] = [
158
198
  maxTokens: 131072,
159
199
  reasoning: true,
160
200
  thinkingLevelMap: {
161
- high: "high",
162
- low: "low",
201
+ high: null,
202
+ low: null,
163
203
  medium: "medium",
164
204
  minimal: null,
165
205
  off: "none",
166
- xhigh: "max",
206
+ xhigh: null,
167
207
  },
168
208
  },
169
209
  {
@@ -240,7 +280,7 @@ export const GENERATED_MODELS: ProviderModelConfig[] = [
240
280
  thinkingLevelMap: {
241
281
  high: "high",
242
282
  low: "low",
243
- medium: "medium",
283
+ medium: null,
244
284
  minimal: null,
245
285
  off: "none",
246
286
  xhigh: "max",
@@ -280,7 +320,7 @@ export const GENERATED_MODELS: ProviderModelConfig[] = [
280
320
  thinkingLevelMap: {
281
321
  high: "high",
282
322
  low: "low",
283
- medium: "medium",
323
+ medium: null,
284
324
  minimal: null,
285
325
  off: "none",
286
326
  xhigh: "max",
@@ -398,12 +438,12 @@ export const GENERATED_MODELS: ProviderModelConfig[] = [
398
438
  maxTokens: 262144,
399
439
  reasoning: true,
400
440
  thinkingLevelMap: {
401
- high: "high",
402
- low: "low",
441
+ high: null,
442
+ low: null,
403
443
  medium: "medium",
404
444
  minimal: null,
405
445
  off: "none",
406
- xhigh: "max",
446
+ xhigh: null,
407
447
  },
408
448
  },
409
449
  {
@@ -438,12 +478,12 @@ export const GENERATED_MODELS: ProviderModelConfig[] = [
438
478
  maxTokens: 262144,
439
479
  reasoning: true,
440
480
  thinkingLevelMap: {
441
- high: "high",
442
- low: "low",
481
+ high: null,
482
+ low: null,
443
483
  medium: "medium",
444
484
  minimal: null,
445
485
  off: "none",
446
- xhigh: "max",
486
+ xhigh: null,
447
487
  },
448
488
  },
449
489
  {
@@ -480,7 +520,7 @@ export const GENERATED_MODELS: ProviderModelConfig[] = [
480
520
  thinkingLevelMap: {
481
521
  high: "high",
482
522
  low: "low",
483
- medium: "medium",
523
+ medium: null,
484
524
  minimal: null,
485
525
  off: "none",
486
526
  xhigh: "max",
@@ -518,12 +558,12 @@ export const GENERATED_MODELS: ProviderModelConfig[] = [
518
558
  maxTokens: 131072,
519
559
  reasoning: true,
520
560
  thinkingLevelMap: {
521
- high: "high",
522
- low: "low",
561
+ high: null,
562
+ low: null,
523
563
  medium: "medium",
524
564
  minimal: null,
525
565
  off: null,
526
- xhigh: "max",
566
+ xhigh: null,
527
567
  },
528
568
  },
529
569
  {
@@ -562,7 +602,7 @@ export const GENERATED_MODELS: ProviderModelConfig[] = [
562
602
  low: "low",
563
603
  medium: "medium",
564
604
  minimal: null,
565
- off: null,
605
+ off: "none",
566
606
  xhigh: "max",
567
607
  },
568
608
  },
@@ -630,12 +670,12 @@ export const GENERATED_MODELS: ProviderModelConfig[] = [
630
670
  maxTokens: 131072,
631
671
  reasoning: true,
632
672
  thinkingLevelMap: {
633
- high: "high",
634
- low: "low",
673
+ high: null,
674
+ low: null,
635
675
  medium: "medium",
636
676
  minimal: null,
637
677
  off: "none",
638
- xhigh: "max",
678
+ xhigh: null,
639
679
  },
640
680
  },
641
681
  {
@@ -670,12 +710,12 @@ export const GENERATED_MODELS: ProviderModelConfig[] = [
670
710
  maxTokens: 65536,
671
711
  reasoning: true,
672
712
  thinkingLevelMap: {
673
- high: "high",
674
- low: "low",
713
+ high: null,
714
+ low: null,
675
715
  medium: "medium",
676
716
  minimal: null,
677
717
  off: "none",
678
- xhigh: "max",
718
+ xhigh: null,
679
719
  },
680
720
  },
681
721
  {
@@ -710,12 +750,12 @@ export const GENERATED_MODELS: ProviderModelConfig[] = [
710
750
  maxTokens: 65536,
711
751
  reasoning: true,
712
752
  thinkingLevelMap: {
713
- high: "high",
714
- low: "low",
753
+ high: null,
754
+ low: null,
715
755
  medium: "medium",
716
756
  minimal: null,
717
757
  off: "none",
718
- xhigh: "max",
758
+ xhigh: null,
719
759
  },
720
760
  },
721
761
  {
package/models.ts CHANGED
@@ -93,7 +93,7 @@ function buildCompat(): ProviderModelConfig["compat"] {
93
93
  return {
94
94
  // Ollama uses "system" role, not "developer" (ollama: docs.ollama.com/api/openai-compatibility, pi: types.ts#supportsDeveloperRole).
95
95
  supportsDeveloperRole: false,
96
- // reasoning_effort works (ollama: docs.ollama.com/api/openai-compatibility, pi: types.ts#supportsReasoningEffort, tested in think-experiment.md).
96
+ // reasoning_effort works (ollama: docs.ollama.com/api/openai-compatibility, pi: types.ts#supportsReasoningEffort).
97
97
  supportsReasoningEffort: true,
98
98
  // "store" is not a supported field (ollama: docs.ollama.com/api/openai-compatibility, pi: types.ts#supportsStore).
99
99
  supportsStore: false,
@@ -109,7 +109,7 @@ function buildCompat(): ProviderModelConfig["compat"] {
109
109
  requiresThinkingAsText: false,
110
110
  // DeepSeek-specific, not needed for Ollama (pi: types.ts#requiresReasoningContentOnAssistantMessages).
111
111
  requiresReasoningContentOnAssistantMessages: false,
112
- // reasoning_effort format works (pi: types.ts#thinkingFormat, tested in think-experiment.md).
112
+ // reasoning_effort format works (pi: types.ts#thinkingFormat).
113
113
  thinkingFormat: "openai",
114
114
  // Ollama does not support tool_choice, so strict mode is unavailable (ollama: docs.ollama.com/api/openai-compatibility, pi: types.ts#supportsStrictMode).
115
115
  supportsStrictMode: false,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-ollama-cloud",
3
- "version": "0.11.0",
3
+ "version": "0.12.0",
4
4
  "type": "module",
5
5
  "keywords": [
6
6
  "pi-package"
@@ -13,6 +13,7 @@
13
13
  "models.ts",
14
14
  "models.generated.ts",
15
15
  "pricing.generated.ts",
16
+ "reasoning.generated.ts",
16
17
  "thinking-levels.ts",
17
18
  "usage.ts",
18
19
  "utils.ts",
@@ -32,8 +33,9 @@
32
33
  "format": "biome format --write .",
33
34
  "test": "vitest run",
34
35
  "smoke:web-tools": "tsx scripts/smoke-web-tools.ts",
35
- "generate-models": "tsx scripts/generate-pricing.ts && tsx scripts/generate-models.ts && biome format --write models.generated.ts pricing.generated.ts",
36
- "generate-limits": "tsx scripts/generate-limits.ts && biome format --write limits.generated.ts"
36
+ "generate-models": "tsx scripts/generate-pricing.ts && tsx scripts/generate-reasoning.ts && tsx scripts/generate-models.ts && biome format --write models.generated.ts pricing.generated.ts reasoning.generated.ts",
37
+ "generate-limits": "tsx scripts/generate-limits.ts",
38
+ "generate-reasoning": "tsx scripts/generate-reasoning.ts && biome format --write reasoning.generated.ts"
37
39
  },
38
40
  "pi": {
39
41
  "extensions": [
@@ -51,6 +53,6 @@
51
53
  "@types/node": "^26.1.2",
52
54
  "@typescript/native-preview": "7.0.0-dev.20260707.2",
53
55
  "tsx": "^4.19.0",
54
- "vitest": "^4.1.6"
56
+ "vitest": "^4.1.11"
55
57
  }
56
58
  }
@@ -1,7 +1,7 @@
1
1
  // Auto-generated by scripts/generate-pricing.ts
2
2
  // Do not edit manually.
3
- // Generated: 2026-09-03T10:12:00.135Z
4
- // Model count: 19
3
+ // Generated: 2026-09-12T02:41:55.206Z
4
+ // Model count: 20
5
5
 
6
6
  export interface ModelPrice {
7
7
  input: number;
@@ -13,6 +13,7 @@ export interface ModelPrice {
13
13
  export const MODEL_PRICING: Record<string, ModelPrice> = {
14
14
  "deepseek-v4-flash:0731": { input: 0.44, output: 1.32, cacheRead: 0.014, cacheWrite: 0 },
15
15
  "deepseek-v4-pro:0813": { input: 1.32, output: 3.96, cacheRead: 0.044, cacheWrite: 0 },
16
+ "deepseek-v4.1-flash": { input: 0.3, output: 1.2, cacheRead: 0.006, cacheWrite: 0 },
16
17
  "gemma4:31b": { input: 0.14, output: 0.4, cacheRead: 0.05, cacheWrite: 0 },
17
18
  "glm-5.1": { input: 1, output: 3.2, cacheRead: 0.2, cacheWrite: 0 },
18
19
  "glm-5.2": { input: 1.4, output: 4.4, cacheRead: 0.26, cacheWrite: 0 },
@@ -0,0 +1,35 @@
1
+ // Auto-generated by scripts/generate-reasoning.ts
2
+ // Do not edit manually.
3
+ // Model count: 22
4
+
5
+ export type ModelsDevReasoningOption =
6
+ | { type: "toggle" }
7
+ | {
8
+ type: "effort";
9
+ values: Array<"none" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max" | "ultra" | "default" | null>;
10
+ };
11
+
12
+ export const MODEL_REASONING_OPTIONS: Record<string, ModelsDevReasoningOption[]> = {
13
+ "deepseek-v4-flash": [{ type: "toggle" }, { type: "effort", values: ["high", "max"] }],
14
+ "deepseek-v4-flash:0731": [{ type: "toggle" }, { type: "effort", values: ["high", "max"] }],
15
+ "deepseek-v4-pro": [{ type: "toggle" }, { type: "effort", values: ["high", "max"] }],
16
+ "deepseek-v4.1-flash": [{ type: "toggle" }, { type: "effort", values: ["low", "high", "max"] }],
17
+ "gemma4:31b": [{ type: "toggle" }],
18
+ "glm-5.1": [{ type: "toggle" }],
19
+ "glm-5.2": [{ type: "effort", values: ["high", "max"] }],
20
+ "glm-5.3": [{ type: "effort", values: ["low", "high", "max"] }],
21
+ "glm-5.3-flash": [{ type: "effort", values: ["low", "high", "max"] }],
22
+ "gpt-oss:120b": [{ type: "effort", values: ["low", "medium", "high"] }],
23
+ "gpt-oss:20b": [{ type: "effort", values: ["low", "medium", "high"] }],
24
+ "kimi-k2.5": [{ type: "toggle" }],
25
+ "kimi-k2.6": [{ type: "toggle" }],
26
+ "kimi-k2.7-code": [{ type: "toggle" }],
27
+ "kimi-k3": [{ type: "toggle" }, { type: "effort", values: ["low", "high", "max"] }],
28
+ "minimax-m2.5": [],
29
+ "minimax-m2.7": [{ type: "toggle" }],
30
+ "minimax-m3": [{ type: "toggle" }, { type: "effort", values: ["low", "medium", "high", "max"] }],
31
+ "nemotron-3-nano:30b": [{ type: "toggle" }],
32
+ "nemotron-3-super": [{ type: "toggle" }],
33
+ "nemotron-3-ultra": [{ type: "toggle" }],
34
+ "qwen3.5:397b": [{ type: "toggle" }],
35
+ };
@@ -2,26 +2,36 @@
2
2
  * Thinking level mapping for Ollama Cloud models.
3
3
  *
4
4
  * Maps Pi's thinking levels to Ollama Cloud's OpenAI-compatible
5
- * `reasoning_effort` values. The API accepts "none", "low", "medium",
6
- * "high", and "max". On simple prompts, "max" can be a no-op over
7
- * "high", but on harder prompts it can increase thinking substantially
8
- * (e.g. deepseek-v4-pro: ~32k tokens on high vs ~55k on max).
5
+ * `reasoning_effort` values. The API accepts "minimal", "none", "low",
6
+ * "medium", "high", "xhigh", "ultra", and "max". On simple prompts, "max" can
7
+ * be a no-op over "high", but on harder prompts it can increase thinking
8
+ * substantially.
9
9
  *
10
- * A `null` value means the level is hidden in Pi's UI.
10
+ * Every value EFFORT_TO_LEVEL can send was verified against the live chat
11
+ * completions API (2026-09-11: "minimal", "xhigh", and "ultra" probed across
12
+ * gpt-oss, deepseek-v4, glm, minimax, and qwen thinking models, all accepted
13
+ * with graded reasoning), so a future models.dev row that lists them passes
14
+ * through a value the endpoint demonstrably accepts.
15
+ *
16
+ * The per-model level support comes from models.dev: scripts/generate-reasoning.ts
17
+ * fetches the `ollama-cloud` provider's `reasoning_options` into
18
+ * reasoning.generated.ts (the same data source pi uses for its built-in
19
+ * providers), and resolve() maps a model's effort values onto Pi's levels.
20
+ * We fall back to models.dev because the Cloud API does not yet expose
21
+ * per-model supported levels (tracked upstream: https://github.com/ollama/ollama/issues/18385).
11
22
  *
12
- * Model-specific behavior discovered through testing (see docs/think-experiment.md):
13
- * - Most models: all levels work, "none" disables thinking
14
- * - GPT-OSS: no off mode, only low/medium/high
15
- * - Qwen 3.x (non-VL): binary-only (think/nothink) - off works
16
- * - Qwen 3 VL: "none" doesn't disable thinking - off is hidden
17
- * - GLM 5.2: off/high/max are exposed; low/medium are hidden
18
- * - Kimi K2 Thinking: "none" doesn't disable thinking - off is hidden
19
- * - MiniMax M2.x: "none" doesn't disable thinking - off is hidden
23
+ * The API exposes only a boolean `thinking` capability plus a global effort
24
+ * vocabulary, and models.dev does not reliably encode the `none` behavior, so
25
+ * the `off` switch is handled separately: it defaults to "none" (a live probe
26
+ * of the current catalog confirmed every model except the OFF_NULL overrides
27
+ * below honors it), and models verified not to honor `none` pin it to null
28
+ * (hidden) via OFF_NULL.
20
29
  *
21
- * Reference: https://docs.ollama.com/api/openai-compatibility
30
+ * A `null` value means the level is hidden in Pi's UI.
22
31
  */
23
32
 
24
33
  import type { ProviderModelConfig } from "@earendil-works/pi-coding-agent";
34
+ import { MODEL_REASONING_OPTIONS, type ModelsDevReasoningOption } from "./reasoning.generated.ts";
25
35
 
26
36
  export type ThinkingLevelMap = NonNullable<ProviderModelConfig["thinkingLevelMap"]>;
27
37
 
@@ -35,63 +45,101 @@ export const DEFAULT: ThinkingLevelMap = {
35
45
  xhigh: "max",
36
46
  };
37
47
 
38
- /** GPT-OSS: can't disable thinking, only low/medium/high.
39
- * https://ollama.com/library/gpt-oss */
40
- export const GPT_OSS: ThinkingLevelMap = {
41
- off: null,
42
- minimal: null,
43
- low: "low",
44
- medium: "medium",
45
- high: "high",
46
- xhigh: null,
47
- };
48
+ /**
49
+ * Models where a live probe of `reasoning_effort:"none"` still produced
50
+ * reasoning (i.e. thinking cannot be disabled), so the `off` level is hidden.
51
+ * Confirmed against the current catalog by scripts/../test probing; the API
52
+ * and models.dev do not expose this behavior.
53
+ */
54
+ /**
55
+ * Models where a live probe of `reasoning_effort:"none"` still produced
56
+ * reasoning (i.e. thinking cannot be disabled), so the `off` level is hidden.
57
+ * Confirmed against the current catalog by scripts/../test probing; the API
58
+ * and models.dev do not expose this behavior.
59
+ *
60
+ * Exact ids hide only the named variant; family prefixes cover future model
61
+ * revisions in the same family. gpt-oss is a family-wide prefix because both
62
+ * probed variants leak and the behavior is documented for the family.
63
+ * minimax is NOT matched family-wide here: minimax-m3 was probed to honor
64
+ * `none`, so only the verified-leaking minimax-m2.7 is pinned by exact id.
65
+ */
66
+ const OFF_NULL_EXACT = new Set(["minimax-m2.7"]);
67
+ const OFF_NULL_FAMILIES = ["gpt-oss"];
48
68
 
49
- /** Qwen 3.x: binary-only (think/nothink), no gradation.
50
- * https://docs.ollama.com/capabilities/thinking */
51
- export const QWEN3: ThinkingLevelMap = {
52
- off: "none",
53
- minimal: null,
54
- low: null,
55
- medium: "medium",
56
- high: null,
57
- xhigh: null,
58
- };
69
+ function hidesOff(id: string): boolean {
70
+ return OFF_NULL_EXACT.has(id) || OFF_NULL_FAMILIES.some((prefix) => id.startsWith(prefix));
71
+ }
59
72
 
60
- /** GLM 5.2: Ollama's model page confirms support for "high" and "max" reasoning efforts.
61
- * https://ollama.com/library/glm-5.2 */
62
- export const GLM_52: ThinkingLevelMap = {
63
- off: "none",
64
- minimal: null,
65
- low: null,
66
- medium: null,
67
- high: "high",
68
- xhigh: "max",
73
+ /**
74
+ * Map a models.dev `effort` value onto the Pi level key and the reasoning_effort
75
+ * string to send for it. Ollama's top effort value is "max"; Pi exposes it via
76
+ * the extra-high level, so "max" (and "xhigh"/"ultra") map to the xhigh key.
77
+ */
78
+ const EFFORT_TO_LEVEL: Record<string, { key: "minimal" | "low" | "medium" | "high" | "xhigh"; value: string }> = {
79
+ minimal: { key: "minimal", value: "minimal" },
80
+ low: { key: "low", value: "low" },
81
+ medium: { key: "medium", value: "medium" },
82
+ high: { key: "high", value: "high" },
83
+ xhigh: { key: "xhigh", value: "xhigh" },
84
+ max: { key: "xhigh", value: "max" },
85
+ ultra: { key: "xhigh", value: "ultra" },
69
86
  };
87
+ /**
88
+ * Build a ThinkingLevelMap from models.dev reasoning_options.
89
+ * Levels come from `effort` values (mapped via EFFORT_TO_LEVEL); `off` defaults
90
+ * to "none" (probe-derived, see file header) and is hidden only via the
91
+ * OFF_NULL exact/family sets (see hidesOff).
92
+ * A toggle-only model is binary (on/off) and exposes a single "medium" level.
93
+ */
94
+ function buildMap(options: readonly ModelsDevReasoningOption[], id: string): ThinkingLevelMap {
95
+ const map: ThinkingLevelMap = {
96
+ off: hidesOff(id) ? null : "none",
97
+ minimal: null,
98
+ low: null,
99
+ medium: null,
100
+ high: null,
101
+ xhigh: null,
102
+ };
70
103
 
71
- /** "none" doesn't disable thinking - off is hidden.
72
- * Used by kimi and minimax families. */
73
- export const NO_OFF: ThinkingLevelMap = {
74
- off: null,
75
- minimal: null,
76
- low: "low",
77
- medium: "medium",
78
- high: "high",
79
- xhigh: "max",
80
- };
104
+ if (options.length > 0 && options.every((option) => option.type === "toggle")) {
105
+ // Binary on/off model: no graded effort, expose a single level.
106
+ return { ...map, medium: "medium" };
107
+ }
108
+
109
+ const efforts = options.flatMap((option) => (option.type === "effort" ? (option.values ?? []) : []));
110
+ for (const effort of efforts) {
111
+ const target = effort !== null && effort !== "default" ? EFFORT_TO_LEVEL[effort] : undefined;
112
+ if (target) map[target.key] = target.value;
113
+ }
114
+ return map;
115
+ }
116
+
117
+ /**
118
+ * Read MODEL_REASONING_OPTIONS[id] without tripping over inherited keys (e.g.
119
+ * "constructor"), which would otherwise resolve to the Object constructor and
120
+ * crash buildMap.
121
+ */
122
+ function ownOptions(id: string): ModelsDevReasoningOption[] | undefined {
123
+ return Object.hasOwn(MODEL_REASONING_OPTIONS, id) ? MODEL_REASONING_OPTIONS[id] : undefined;
124
+ }
81
125
 
82
126
  /**
83
127
  * Resolve the thinking level map for a model.
84
- * Matches by model ID prefix (case-sensitive, checks first chars).
128
+ * Looks up the model id (exact, then `:tag` family) in the generated models.dev
129
+ * table, falling back to DEFAULT for models with no entry. The matched key is
130
+ * the one passed to buildMap so the OFF_NULL set (keyed on bare family names)
131
+ * applies to tagged ids that resolve through a family match.
85
132
  */
86
133
  export function resolve(id: string, capabilities: string[]): ThinkingLevelMap | undefined {
87
134
  if (!capabilities.includes("thinking")) return undefined;
88
135
 
89
- if (id.startsWith("gpt-oss")) return GPT_OSS;
90
- if (id === "glm-5.2") return GLM_52;
91
- if (id.startsWith("qwen3-vl")) return NO_OFF;
92
- if (id.startsWith("qwen3")) return QWEN3;
93
- if (id === "kimi-k2-thinking") return NO_OFF;
94
- if (id.startsWith("minimax")) return NO_OFF;
95
-
96
- return DEFAULT;
136
+ const colon = id.lastIndexOf(":");
137
+ const exact = ownOptions(id);
138
+ const matchedKey = exact !== undefined ? id : colon > 0 ? id.slice(0, colon) : "";
139
+ const options = exact ?? ownOptions(matchedKey);
140
+ // An empty array (e.g. minimax-m2.5) carries no verified options; fall back
141
+ // to DEFAULT rather than a degenerate map whose only selectable level can
142
+ // be a leaking off.
143
+ if (options === undefined || options.length === 0) return DEFAULT;
144
+ return buildMap(options, matchedKey);
97
145
  }