pi-free 2.0.10 → 2.0.12

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.
@@ -18,7 +18,11 @@ import type {
18
18
  } from "@earendil-works/pi-coding-agent";
19
19
  import { getOpencodeShowPaid, getOpenrouterShowPaid } from "../config.ts";
20
20
  import { createLogger } from "./logger.ts";
21
- import { isFreeModel, registerWithGlobalToggle } from "./registry.ts";
21
+ import {
22
+ getProviderRegistry,
23
+ isFreeModel,
24
+ registerWithGlobalToggle,
25
+ } from "./registry.ts";
22
26
  import { createToggleState } from "./toggle-state.ts";
23
27
 
24
28
  const _logger = createLogger("built-in-toggle");
@@ -55,68 +59,38 @@ let commandsRegistered = false;
55
59
  // =============================================================================
56
60
 
57
61
  export function setupBuiltInProviderToggles(pi: ExtensionAPI): void {
62
+ const activeConfigs = BUILT_IN_TOGGLE_PROVIDERS.filter(
63
+ (config) => !getProviderRegistry().has(config.id),
64
+ );
65
+
66
+ if (activeConfigs.length === 0) {
67
+ _logger.info(
68
+ "[built-in-toggle] OpenCode/OpenRouter already registered dynamically; skipping fallback capture",
69
+ );
70
+ return;
71
+ }
72
+
58
73
  // Register toggle commands once (available even before models load)
59
74
  if (!commandsRegistered) {
60
- for (const config of BUILT_IN_TOGGLE_PROVIDERS) {
75
+ for (const config of activeConfigs) {
61
76
  registerToggleCommand(pi, config);
62
77
  }
63
- setupStatusBar(pi);
78
+ setupStatusBar(pi, activeConfigs);
64
79
  commandsRegistered = true;
65
80
  }
66
81
 
67
82
  // Capture built-in models on session start and apply initial filter
68
83
  pi.on("session_start", async (_event, ctx) => {
69
- const available = ctx.modelRegistry.getAvailable();
70
-
71
- for (const config of BUILT_IN_TOGGLE_PROVIDERS) {
84
+ for (const config of activeConfigs) {
72
85
  if (providerStates.has(config.id)) {
73
- // Already captured this session — skip to avoid re-registering
86
+ // Already captured — skip to avoid re-registering
74
87
  continue;
75
88
  }
76
89
 
77
- const providerModels = available.filter(
78
- (m: Model<Api>) => m.provider === config.id,
79
- );
80
- if (providerModels.length === 0) continue;
81
-
82
- const allModels = providerModels.map(modelToProviderConfig);
83
- const freeModels = allModels.filter((m) =>
84
- isFreeModel({ ...m, provider: config.id }, allModels),
85
- );
86
-
87
- const baseUrl = providerModels[0].baseUrl;
88
- const api = providerModels[0].api;
89
- const apiKeyEnv = getApiKeyEnvForProvider(config.id);
90
-
91
- const reRegister = (models: ProviderModelConfig[]) => {
92
- pi.registerProvider(config.id, {
93
- baseUrl,
94
- apiKey: apiKeyEnv,
95
- api,
96
- models,
97
- });
98
- };
99
-
100
- const stored = { free: freeModels, all: allModels };
101
- const toggleState = createToggleState<ProviderModelConfig>({
102
- providerId: config.id,
103
- initialShowPaid: config.getShowPaid(),
104
- initialModels: stored,
105
- });
106
-
107
- providerStates.set(config.id, {
108
- stored,
109
- reRegister,
110
- toggleState,
111
- });
112
-
113
- registerWithGlobalToggle(config.id, stored, reRegister, true);
90
+ const state = tryCaptureProvider(pi, config, ctx);
91
+ if (!state) continue;
114
92
 
115
- _logger.info(
116
- `[built-in-toggle] ${config.id}: captured ${allModels.length} models (${freeModels.length} free)`,
117
- );
118
-
119
- const applied = toggleState.applyCurrent(reRegister);
93
+ const applied = state.toggleState.applyCurrent(state.reRegister);
120
94
  _logger.info(
121
95
  `[built-in-toggle] ${config.id}: applied ${applied.mode} mode with ${applied.models.length} models`,
122
96
  );
@@ -124,6 +98,58 @@ export function setupBuiltInProviderToggles(pi: ExtensionAPI): void {
124
98
  });
125
99
  }
126
100
 
101
+ // =============================================================================
102
+ // On-demand model capture (called by toggle command when state is missing)
103
+ // =============================================================================
104
+
105
+ function tryCaptureProvider(
106
+ pi: ExtensionAPI,
107
+ config: BuiltInToggleConfig,
108
+ ctx: any,
109
+ ): BuiltInProviderState | undefined {
110
+ const available = ctx.modelRegistry.getAvailable();
111
+ const providerModels = available.filter(
112
+ (m: Model<Api>) => m.provider === config.id,
113
+ );
114
+ if (providerModels.length === 0) return undefined;
115
+
116
+ const allModels = providerModels.map(modelToProviderConfig);
117
+ const freeModels = allModels.filter((m: ProviderModelConfig) =>
118
+ isFreeModel({ ...m, provider: config.id }, allModels),
119
+ );
120
+
121
+ const baseUrl = providerModels[0].baseUrl;
122
+ const api = providerModels[0].api;
123
+ const apiKeyEnv = getApiKeyEnvForProvider(config.id);
124
+
125
+ const reRegister = (models: ProviderModelConfig[]) => {
126
+ pi.registerProvider(config.id, {
127
+ baseUrl,
128
+ apiKey: apiKeyEnv,
129
+ api,
130
+ models,
131
+ });
132
+ };
133
+
134
+ const stored = { free: freeModels, all: allModels };
135
+ const toggleState = createToggleState<ProviderModelConfig>({
136
+ providerId: config.id,
137
+ initialShowPaid: config.getShowPaid(),
138
+ initialModels: stored,
139
+ });
140
+
141
+ const state: BuiltInProviderState = { stored, reRegister, toggleState };
142
+ providerStates.set(config.id, state);
143
+
144
+ registerWithGlobalToggle(config.id, stored, reRegister, true);
145
+
146
+ _logger.info(
147
+ `[built-in-toggle] ${config.id}: captured ${allModels.length} models (${freeModels.length} free)`,
148
+ );
149
+
150
+ return state;
151
+ }
152
+
127
153
  // =============================================================================
128
154
  // Per-provider toggle command
129
155
  // =============================================================================
@@ -136,13 +162,17 @@ function registerToggleCommand(
136
162
  pi.registerCommand(commandName, {
137
163
  description: `Toggle free/paid ${config.id} models`,
138
164
  handler: async (_args, ctx) => {
139
- const state = providerStates.get(config.id);
165
+ let state = providerStates.get(config.id);
140
166
  if (!state) {
141
- ctx.ui.notify(
142
- `${config.id}: models not loaded yet. Start a session first.`,
143
- "warning",
144
- );
145
- return;
167
+ // Models may have loaded after session_start — try on-demand capture
168
+ state = tryCaptureProvider(pi, config, ctx);
169
+ if (!state) {
170
+ ctx.ui.notify(
171
+ `${config.id}: models not loaded yet. Start a session first.`,
172
+ "warning",
173
+ );
174
+ return;
175
+ }
146
176
  }
147
177
 
148
178
  const applied = state.toggleState.toggle(state.reRegister);
@@ -185,12 +215,15 @@ function modelToProviderConfig(m: Model<Api>): ProviderModelConfig {
185
215
  // Status bar for provider selection
186
216
  // =============================================================================
187
217
 
188
- function setupStatusBar(pi: ExtensionAPI): void {
218
+ function setupStatusBar(
219
+ pi: ExtensionAPI,
220
+ configs: BuiltInToggleConfig[],
221
+ ): void {
189
222
  pi.on("model_select", (_event, ctx) => {
190
223
  const selected = _event.model?.provider;
191
224
 
192
- // Clear status for all built-in toggle providers
193
- for (const config of BUILT_IN_TOGGLE_PROVIDERS) {
225
+ // Clear status for all fallback-captured built-in providers
226
+ for (const config of configs) {
194
227
  if (selected !== config.id) {
195
228
  ctx.ui.setStatus(`${config.id}-status`, undefined);
196
229
  }
package/lib/registry.ts CHANGED
@@ -9,7 +9,7 @@ import type {
9
9
  ExtensionAPI,
10
10
  ProviderModelConfig,
11
11
  } from "@earendil-works/pi-coding-agent";
12
- import { getFreeOnly, saveConfig } from "../config.ts";
12
+ import { getFreeOnly, getProviderShowPaid, saveConfig } from "../config.ts";
13
13
  import { createLogger } from "./logger.ts";
14
14
 
15
15
  const _logger = createLogger("pi-free");
@@ -82,7 +82,7 @@ function detectPricingExposed(allModels: ProviderModelConfig[]): boolean {
82
82
  * @returns true if the model is definitively free per the provider's API
83
83
  */
84
84
  export function isFreeModel(
85
- model: ProviderModelConfig & { provider?: string },
85
+ model: ProviderModelConfig & { provider?: string; _pricingKnown?: boolean },
86
86
  allModels?: ProviderModelConfig[],
87
87
  ): boolean {
88
88
  return isFreeModelInternal(model, allModels);
@@ -90,7 +90,7 @@ export function isFreeModel(
90
90
 
91
91
  // Internal implementation to work around TypeScript filter callback issues
92
92
  function isFreeModelInternal(
93
- model: ProviderModelConfig & { provider?: string },
93
+ model: ProviderModelConfig & { provider?: string; _pricingKnown?: boolean },
94
94
  allModels: ProviderModelConfig[] | undefined,
95
95
  ): boolean {
96
96
  // Determine if pricing is exposed
@@ -106,12 +106,20 @@ function isFreeModelInternal(
106
106
  pricingExposed = true;
107
107
  }
108
108
 
109
- // Route A: Pricing-exposed providers - use OR logic
110
- // Model is free if EITHER cost is zero OR name contains "free"
109
+ // Route A: Pricing-exposed providers
110
+ // Model is free if EITHER cost is zero OR name contains "free".
111
+ // BUT: when _pricingKnown is explicitly false (API returned no pricing data),
112
+ // cost values are untrustworthy defaults — fall back to name-only detection.
111
113
  if (pricingExposed) {
112
114
  const isZeroCost =
113
115
  (model.cost?.input ?? 0) === 0 && (model.cost?.output ?? 0) === 0;
114
116
  const hasFreeInName = model.name.toLowerCase().includes("free");
117
+
118
+ // Pricing missing for this specific model — only trust name-based signal
119
+ if (model._pricingKnown === false) {
120
+ return hasFreeInName;
121
+ }
122
+
115
123
  return isZeroCost || hasFreeInName;
116
124
  }
117
125
 
@@ -156,12 +164,32 @@ export function getProviderRegistry(): ReadonlyMap<string, ProviderEntry> {
156
164
  // Global filter application
157
165
  // =============================================================================
158
166
 
167
+ function showAllForProvider(providerId: string, entry: ProviderEntry): void {
168
+ const allModels =
169
+ entry.stored.all.length > 0 ? entry.stored.all : entry.stored.free;
170
+ if (allModels.length > 0) {
171
+ entry.reRegister(allModels);
172
+ _logger.info(
173
+ `[pi-free] ${providerId}: showing all ${allModels.length} models`,
174
+ );
175
+ }
176
+ }
177
+
159
178
  function applyFilterToProvider(
160
179
  providerId: string,
161
180
  entry: ProviderEntry,
162
181
  freeOnly: boolean,
182
+ force: boolean,
163
183
  ): void {
164
184
  if (freeOnly) {
185
+ if (!force && getProviderShowPaid(providerId)) {
186
+ showAllForProvider(providerId, entry);
187
+ _logger.info(
188
+ `[pi-free] ${providerId}: preserved persisted all-models toggle`,
189
+ );
190
+ return;
191
+ }
192
+
165
193
  if (entry.stored.free.length > 0) {
166
194
  entry.reRegister(entry.stored.free);
167
195
  _logger.info(
@@ -171,25 +199,21 @@ function applyFilterToProvider(
171
199
  _logger.warn(`[pi-free] ${providerId}: no free models available`);
172
200
  }
173
201
  } else {
174
- // Show all models (paid + free)
175
- const allModels =
176
- entry.stored.all.length > 0 ? entry.stored.all : entry.stored.free;
177
- if (allModels.length > 0) {
178
- entry.reRegister(allModels);
179
- _logger.info(
180
- `[pi-free] ${providerId}: showing all ${allModels.length} models`,
181
- );
182
- }
202
+ showAllForProvider(providerId, entry);
183
203
  }
184
204
  }
185
205
 
186
- export function applyGlobalFilter(_pi: ExtensionAPI, freeOnly: boolean): void {
206
+ export function applyGlobalFilter(
207
+ _pi: ExtensionAPI,
208
+ freeOnly: boolean,
209
+ options: { force?: boolean } = {},
210
+ ): void {
187
211
  globalFreeOnly = freeOnly;
188
212
  saveConfig({ free_only: freeOnly });
189
213
 
190
214
  for (const [providerId, entry] of providerRegistry) {
191
215
  try {
192
- applyFilterToProvider(providerId, entry, freeOnly);
216
+ applyFilterToProvider(providerId, entry, freeOnly, options.force === true);
193
217
  } catch (err) {
194
218
  _logger.error(
195
219
  `[pi-free] Failed to apply filter to ${providerId}`,