pi-free 1.0.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.
Files changed (68) hide show
  1. package/.github/workflows/update-benchmarks.yml +67 -0
  2. package/.pi/skills/pi-extension-dev/SKILL.md +155 -0
  3. package/CHANGELOG.md +59 -0
  4. package/LICENSE +21 -0
  5. package/README.md +289 -0
  6. package/config.ts +224 -0
  7. package/constants.ts +110 -0
  8. package/docs/free-tier-limits.md +213 -0
  9. package/docs/model-hopping.md +214 -0
  10. package/docs/plans/file-reorganization.md +172 -0
  11. package/docs/plans/package-json-fix.md +143 -0
  12. package/docs/provider-failover-plan.md +279 -0
  13. package/lib/json-persistence.ts +102 -0
  14. package/lib/logger.ts +94 -0
  15. package/lib/model-enhancer.ts +20 -0
  16. package/lib/types.ts +108 -0
  17. package/lib/util.ts +256 -0
  18. package/package.json +52 -0
  19. package/provider-factory.ts +221 -0
  20. package/provider-failover/errors.ts +275 -0
  21. package/provider-failover/hardcoded-benchmarks.ts +9889 -0
  22. package/provider-failover/index.ts +194 -0
  23. package/provider-helper.ts +336 -0
  24. package/providers/cline-auth.ts +473 -0
  25. package/providers/cline-models.ts +77 -0
  26. package/providers/cline.ts +257 -0
  27. package/providers/factory.ts +125 -0
  28. package/providers/fireworks.ts +49 -0
  29. package/providers/kilo-auth.ts +172 -0
  30. package/providers/kilo-models.ts +26 -0
  31. package/providers/kilo.ts +144 -0
  32. package/providers/mistral.ts +144 -0
  33. package/providers/model-fetcher.ts +138 -0
  34. package/providers/nvidia.ts +97 -0
  35. package/providers/ollama.ts +113 -0
  36. package/providers/openrouter.ts +175 -0
  37. package/providers/zen.ts +416 -0
  38. package/scripts/update-benchmarks.ts +255 -0
  39. package/tests/cline.test.ts +149 -0
  40. package/tests/errors.test.ts +139 -0
  41. package/tests/failover.test.ts +94 -0
  42. package/tests/fireworks.test.ts +148 -0
  43. package/tests/free-tier-limits.test.ts +191 -0
  44. package/tests/json-persistence.test.ts +105 -0
  45. package/tests/kilo.test.ts +186 -0
  46. package/tests/mistral.test.ts +138 -0
  47. package/tests/nvidia.test.ts +55 -0
  48. package/tests/ollama.test.ts +261 -0
  49. package/tests/openrouter.test.ts +192 -0
  50. package/tests/usage-tracking.test.ts +150 -0
  51. package/tests/util.test.ts +413 -0
  52. package/tests/zen.test.ts +180 -0
  53. package/todo.md +153 -0
  54. package/tsconfig.json +26 -0
  55. package/usage/commands.ts +17 -0
  56. package/usage/cumulative.ts +193 -0
  57. package/usage/formatters.ts +131 -0
  58. package/usage/index.ts +46 -0
  59. package/usage/limits.ts +166 -0
  60. package/usage/metrics.ts +222 -0
  61. package/usage/sessions.ts +355 -0
  62. package/usage/store.ts +99 -0
  63. package/usage/tracking.ts +329 -0
  64. package/usage/widget.ts +90 -0
  65. package/vitest.config.ts +20 -0
  66. package/widget/data.ts +113 -0
  67. package/widget/format.ts +26 -0
  68. package/widget/render.ts +117 -0
@@ -0,0 +1,194 @@
1
+ /**
2
+ * Main provider failover handler
3
+ * Coordinates error detection and provider switching
4
+ */
5
+
6
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
7
+ import { createLogger } from "../lib/logger.ts";
8
+ import {
9
+ type ClassifiedError,
10
+ classifyError,
11
+ logErrorClassification,
12
+ } from "./errors.js";
13
+
14
+ const _logger = createLogger("failover");
15
+
16
+ export interface FailoverConfig {
17
+ // Provider identifier (e.g., "kilo", "openrouter")
18
+ provider: string;
19
+
20
+ // Whether this provider is in paid mode
21
+ isPaidMode: boolean;
22
+ }
23
+
24
+ export interface FailoverResult {
25
+ action: "retry" | "fail";
26
+ message: string;
27
+ shouldRetry: boolean;
28
+ retryDelayMs?: number;
29
+ }
30
+
31
+ // Track consecutive failures per provider
32
+ const failureCounts = new Map<string, number>();
33
+ const MAX_CONSECUTIVE_FAILURES = 3;
34
+
35
+ /**
36
+ * Handle provider error with smart failover logic
37
+ */
38
+ export async function handleProviderError(
39
+ error: unknown,
40
+ config: FailoverConfig,
41
+ _pi: ExtensionAPI,
42
+ ctx: {
43
+ ui: {
44
+ notify: (message: string, type: "info" | "warning" | "error") => void;
45
+ };
46
+ session?: { id?: string };
47
+ },
48
+ ): Promise<FailoverResult> {
49
+ const { provider, isPaidMode } = config;
50
+
51
+ // Classify the error
52
+ const classified = classifyError(error);
53
+ logErrorClassification(error, classified);
54
+
55
+ // Track failures
56
+ const failureKey = `${provider}`;
57
+ const currentFailures = (failureCounts.get(failureKey) ?? 0) + 1;
58
+ failureCounts.set(failureKey, currentFailures);
59
+
60
+ // Check for too many consecutive failures
61
+ if (currentFailures >= MAX_CONSECUTIVE_FAILURES) {
62
+ _logger.info(`${provider} has ${currentFailures} consecutive failures`);
63
+ }
64
+
65
+ switch (classified.type) {
66
+ case "rate_limit":
67
+ return handleRateLimit(classified, provider, isPaidMode, ctx);
68
+
69
+ case "capacity":
70
+ return handleCapacityError(classified, provider);
71
+
72
+ case "auth":
73
+ return handleAuthError(classified, provider);
74
+
75
+ case "network":
76
+ return handleNetworkError(classified, provider);
77
+
78
+ default:
79
+ return handleUnknownError(classified, provider);
80
+ }
81
+ }
82
+
83
+ /**
84
+ * Handle rate limit (429) error
85
+ */
86
+ function handleRateLimit(
87
+ classified: ClassifiedError,
88
+ provider: string,
89
+ isPaidMode: boolean,
90
+ _ctx: {
91
+ ui: {
92
+ notify: (message: string, type: "info" | "warning" | "error") => void;
93
+ };
94
+ },
95
+ ): FailoverResult {
96
+ _logger.info(`Rate limit on ${provider}`, { isPaidMode });
97
+
98
+ const waitTime = Math.round((classified.retryAfterMs ?? 60000) / 1000);
99
+
100
+ return {
101
+ action: "fail",
102
+ message: `Rate limit on ${provider}. Wait ${waitTime}s or switch providers manually with /model.`,
103
+ shouldRetry: false,
104
+ retryDelayMs: classified.retryAfterMs,
105
+ };
106
+ }
107
+
108
+ /**
109
+ * Handle capacity error (provider overloaded)
110
+ */
111
+ function handleCapacityError(
112
+ classified: ClassifiedError,
113
+ provider: string,
114
+ ): FailoverResult {
115
+ _logger.info(`Capacity error on ${provider}`);
116
+
117
+ return {
118
+ action: "retry",
119
+ message: `${provider} is at capacity. Try again in ${Math.round((classified.retryAfterMs ?? 30000) / 1000)}s or switch providers.`,
120
+ shouldRetry: true,
121
+ retryDelayMs: classified.retryAfterMs ?? 30000,
122
+ };
123
+ }
124
+
125
+ /**
126
+ * Handle authentication error
127
+ */
128
+ function handleAuthError(
129
+ _classified: ClassifiedError,
130
+ provider: string,
131
+ ): FailoverResult {
132
+ _logger.info(`Auth error on ${provider}`);
133
+
134
+ return {
135
+ action: "fail",
136
+ message: `Authentication failed for ${provider}. Check your API key with /login ${provider} or set ${provider.toUpperCase()}_API_KEY.`,
137
+ shouldRetry: false,
138
+ };
139
+ }
140
+
141
+ /**
142
+ * Handle network error
143
+ */
144
+ function handleNetworkError(
145
+ classified: ClassifiedError,
146
+ provider: string,
147
+ ): FailoverResult {
148
+ _logger.info(`Network error on ${provider}`);
149
+
150
+ return {
151
+ action: "retry",
152
+ message: `Network error connecting to ${provider}. Retrying...`,
153
+ shouldRetry: true,
154
+ retryDelayMs: classified.retryAfterMs ?? 5000,
155
+ };
156
+ }
157
+
158
+ /**
159
+ * Handle unknown/unclassified error
160
+ */
161
+ function handleUnknownError(
162
+ classified: ClassifiedError,
163
+ provider: string,
164
+ ): FailoverResult {
165
+ _logger.info(`Unknown error on ${provider}`, { message: classified.message });
166
+
167
+ return {
168
+ action: classified.retryable ? "retry" : "fail",
169
+ message: `Error from ${provider}: ${classified.message.slice(0, 100)}`,
170
+ shouldRetry: classified.retryable,
171
+ retryDelayMs: classified.retryAfterMs ?? 10000,
172
+ };
173
+ }
174
+
175
+ /**
176
+ * Reset failure count for a provider (call on successful request)
177
+ */
178
+ export function resetFailureCount(provider: string): void {
179
+ failureCounts.delete(provider);
180
+ }
181
+
182
+ /**
183
+ * Get current failure count for a provider
184
+ */
185
+ export function getFailureCount(provider: string): number {
186
+ return failureCounts.get(provider) ?? 0;
187
+ }
188
+
189
+ /**
190
+ * Check if provider should be considered exhausted
191
+ */
192
+ export function isProviderExhausted(provider: string): boolean {
193
+ return getFailureCount(provider) >= MAX_CONSECUTIVE_FAILURES;
194
+ }
@@ -0,0 +1,336 @@
1
+ /**
2
+ * Shared provider setup helpers for pi-free-providers.
3
+ * Extracts the common boilerplate pattern repeated across providers:
4
+ * - /{provider}-toggle command to switch between free/paid models
5
+ * - model_select handler (clear status for other providers)
6
+ * - turn_end handler (increment request count, handle errors)
7
+ * - before_agent_start handler (one-time ToS notice)
8
+ */
9
+
10
+ import type {
11
+ ExtensionAPI,
12
+ ProviderModelConfig,
13
+ } from "@mariozechner/pi-coding-agent";
14
+ import { saveConfig } from "./config.ts";
15
+ import { createLogger } from "./lib/logger.ts";
16
+ import { enhanceModelNameWithCodingIndex } from "./provider-failover/hardcoded-benchmarks.js";
17
+ import {
18
+ handleProviderError,
19
+ isProviderExhausted,
20
+ resetFailureCount,
21
+ } from "./provider-failover/index.js";
22
+ import { incrementRequestCount } from "./usage/metrics.ts";
23
+ import { incrementModelRequestCount } from "./usage/tracking.ts";
24
+
25
+ const _logger = createLogger("provider-helper");
26
+
27
+ // =============================================================================
28
+ // Types
29
+ // =============================================================================
30
+
31
+ export interface ProviderSetupConfig {
32
+ /** Provider identifier (e.g., "kilo", "openrouter"). */
33
+ providerId: string;
34
+ /** Terms of service URL. If set, shows a one-time notice on first free use. */
35
+ tosUrl?: string;
36
+ /** When true, suppresses the "free models / set API key" ToS notice. */
37
+ hasKey?: boolean;
38
+ /** Initial mode - auto-detected from config at startup. */
39
+ initialShowPaid?: boolean;
40
+ /**
41
+ * Called by /{provider}-toggle command to re-register
42
+ * the provider with the given model set.
43
+ */
44
+ reRegister: (models: ProviderModelConfig[], stored: StoredModels) => void;
45
+ /** Optional custom error handler. Return true if handled. */
46
+ onError?: (
47
+ error: unknown,
48
+ ctx: {
49
+ ui: { notify: (m: string, t: "info" | "warning" | "error") => void };
50
+ },
51
+ ) => Promise<boolean>;
52
+ }
53
+
54
+ export interface StoredModels {
55
+ free: ProviderModelConfig[];
56
+ all: ProviderModelConfig[];
57
+ }
58
+
59
+ // =============================================================================
60
+ // Provider Registration Helpers
61
+ // =============================================================================
62
+
63
+ export interface OpenAICompatibleConfig {
64
+ /** Provider identifier (e.g., "nvidia", "fireworks") */
65
+ providerId: string;
66
+ /** Base URL for the API */
67
+ baseUrl: string;
68
+ /** Environment variable name for the API key */
69
+ apiKey: string;
70
+ /** Additional headers to include */
71
+ headers?: Record<string, string>;
72
+ /** OAuth configuration (optional) */
73
+ oauth?: {
74
+ name: string;
75
+ login: (callbacks: unknown) => Promise<unknown>;
76
+ refreshToken?: (cred: unknown) => Promise<unknown>;
77
+ getApiKey?: (cred: unknown) => string;
78
+ };
79
+ }
80
+
81
+ /**
82
+ * Enhance all model names with Coding Index scores
83
+ * Use this for direct provider registration (not through setupProvider)
84
+ */
85
+ export function enhanceWithCI(
86
+ models: ProviderModelConfig[],
87
+ ): ProviderModelConfig[] {
88
+ return models.map((m) => ({
89
+ ...m,
90
+ name: enhanceModelNameWithCodingIndex(m.name, m.id),
91
+ }));
92
+ }
93
+
94
+ /**
95
+ * Register an OpenAI-compatible provider with standard headers.
96
+ * Reduces boilerplate across providers that use the OpenAI API format.
97
+ */
98
+ export function registerOpenAICompatible(
99
+ pi: ExtensionAPI,
100
+ config: OpenAICompatibleConfig,
101
+ models: ProviderModelConfig[],
102
+ ): void {
103
+ const { providerId, baseUrl, apiKey, headers, oauth } = config;
104
+
105
+ pi.registerProvider(providerId, {
106
+ baseUrl,
107
+ apiKey,
108
+ api: "openai-completions" as const,
109
+ headers: {
110
+ "User-Agent": "pi-free-providers",
111
+ ...headers,
112
+ },
113
+ models: enhanceWithCI(models),
114
+ ...(oauth && { oauth: oauth as any }),
115
+ });
116
+ }
117
+
118
+ /**
119
+ * Create a reRegister function for use with setupProvider.
120
+ * Returns a function that re-registers the provider with new models.
121
+ */
122
+ export function createReRegister(
123
+ pi: ExtensionAPI,
124
+ config: OpenAICompatibleConfig,
125
+ ): (models: ProviderModelConfig[]) => void {
126
+ return (models: ProviderModelConfig[]) => {
127
+ registerOpenAICompatible(pi, config, models);
128
+ };
129
+ }
130
+
131
+ /**
132
+ * Create a reRegister function that uses ctx.modelRegistry.registerProvider.
133
+ * Used by providers that need to register with runtime context (session_start handlers).
134
+ */
135
+ export function createCtxReRegister(
136
+ ctx: {
137
+ modelRegistry: { registerProvider: (id: string, config: unknown) => void };
138
+ },
139
+ config: OpenAICompatibleConfig,
140
+ ): (models: ProviderModelConfig[]) => void {
141
+ const { providerId, baseUrl, apiKey, headers, oauth } = config;
142
+
143
+ return (models: ProviderModelConfig[]) => {
144
+ ctx.modelRegistry.registerProvider(providerId, {
145
+ baseUrl,
146
+ apiKey,
147
+ api: "openai-completions" as const,
148
+ headers: {
149
+ "User-Agent": "pi-free-providers",
150
+ ...headers,
151
+ },
152
+ models: enhanceWithCI(models),
153
+ ...(oauth && { oauth: oauth as any }),
154
+ });
155
+ };
156
+ }
157
+
158
+ /**
159
+ * Get the config key name for a provider's show_paid setting.
160
+ */
161
+ function getShowPaidConfigKey(providerId: string): string {
162
+ return `${providerId}_show_paid`;
163
+ }
164
+
165
+ export function setupProvider(
166
+ pi: ExtensionAPI,
167
+ config: ProviderSetupConfig,
168
+ stored: StoredModels,
169
+ ): void {
170
+ const { providerId, tosUrl, initialShowPaid = false } = config;
171
+
172
+ // Track current mode (synced with config)
173
+ let currentShowPaid = initialShowPaid;
174
+
175
+ // Wrap reRegister to automatically add CI scores to all models
176
+ const reRegister = (models: ProviderModelConfig[], _s: StoredModels) => {
177
+ const enhanced = enhanceWithCI(models);
178
+ config.reRegister(enhanced, _s);
179
+ };
180
+
181
+ // ── Single toggle command ──────────────────────────────────────────
182
+
183
+ pi.registerCommand(`${providerId}-toggle`, {
184
+ description: `Toggle between free and all ${providerId} models`,
185
+ handler: async (_args, ctx) => {
186
+ // Toggle the mode
187
+ currentShowPaid = !currentShowPaid;
188
+
189
+ // Persist to config file
190
+ const configKey = getShowPaidConfigKey(providerId);
191
+ saveConfig({ [configKey]: currentShowPaid });
192
+
193
+ // Re-register with appropriate model set
194
+ if (currentShowPaid) {
195
+ if (stored.all.length === 0) {
196
+ ctx.ui.notify("No models available", "warning");
197
+ return;
198
+ }
199
+ reRegister(stored.all, stored);
200
+ ctx.ui.notify(
201
+ `${providerId}: showing all ${stored.all.length} models (including paid)`,
202
+ "info",
203
+ );
204
+ } else {
205
+ if (stored.free.length === 0) {
206
+ ctx.ui.notify("No free models loaded", "warning");
207
+ return;
208
+ }
209
+ reRegister(stored.free, stored);
210
+ ctx.ui.notify(
211
+ `${providerId}: showing ${stored.free.length} free models`,
212
+ "info",
213
+ );
214
+ }
215
+ },
216
+ });
217
+
218
+ // ── Clear status when another provider is selected ───────────────────
219
+
220
+ pi.on("model_select", (_event, ctx) => {
221
+ if (_event.model?.provider !== providerId) {
222
+ ctx.ui.setStatus(`${providerId}-status`, undefined);
223
+ }
224
+ });
225
+
226
+ // ── Track request count, reset failure count, handle errors ──────────
227
+
228
+ pi.on("turn_end", async (event, ctx) => {
229
+ if (ctx.model?.provider !== providerId) return;
230
+
231
+ const msg = (
232
+ event as { message?: { role?: string; errorMessage?: string } }
233
+ ).message;
234
+
235
+ // Check for errors in the assistant message
236
+ if (msg?.role === "assistant" && msg.errorMessage) {
237
+ const errorMsg = msg.errorMessage;
238
+ _logger.info("Error detected", {
239
+ provider: providerId,
240
+ error: errorMsg.slice(0, 100),
241
+ });
242
+
243
+ // Use custom error handler if provided
244
+ if (config.onError) {
245
+ const handled = await config.onError(errorMsg, ctx);
246
+ if (handled) return;
247
+ }
248
+
249
+ // Use default failover handler
250
+ const result = await handleProviderError(
251
+ errorMsg,
252
+ {
253
+ provider: providerId,
254
+ isPaidMode: currentShowPaid,
255
+ },
256
+ pi,
257
+ ctx as {
258
+ ui: {
259
+ notify: (m: string, t: "info" | "warning" | "error") => void;
260
+ };
261
+ session?: { id?: string };
262
+ },
263
+ );
264
+
265
+ // Show notification based on result
266
+ if (result.action === "retry") {
267
+ ctx.ui.notify(result.message, "warning");
268
+ if (isProviderExhausted(providerId)) {
269
+ ctx.ui.setStatus(
270
+ `${providerId}-status`,
271
+ ctx.ui.theme.fg("dim", "⚠️ Rate limited - consider switching"),
272
+ );
273
+ }
274
+ } else if (result.action === "fail") {
275
+ ctx.ui.notify(result.message, "error");
276
+ }
277
+
278
+ // Don't reset failure count on error
279
+ return;
280
+ }
281
+
282
+ // Success - reset failure count and increment metrics
283
+ incrementRequestCount(providerId);
284
+
285
+ // Track per-model usage if we have a model selected
286
+ const modelId = ctx.model?.id;
287
+ if (modelId) {
288
+ // Extract token usage from the event if available
289
+ const msg = (
290
+ event as {
291
+ message?: {
292
+ usage?: {
293
+ input?: number;
294
+ output?: number;
295
+ cacheRead?: number;
296
+ cacheWrite?: number;
297
+ cost?: { total?: number };
298
+ };
299
+ };
300
+ }
301
+ ).message;
302
+ const tokensIn = msg?.usage?.input ?? 0;
303
+ const tokensOut = msg?.usage?.output ?? 0;
304
+ const cacheRead = msg?.usage?.cacheRead ?? 0;
305
+ const cacheWrite = msg?.usage?.cacheWrite ?? 0;
306
+ const cost = msg?.usage?.cost?.total ?? 0;
307
+ incrementModelRequestCount(
308
+ providerId,
309
+ modelId,
310
+ tokensIn,
311
+ tokensOut,
312
+ cacheRead,
313
+ cacheWrite,
314
+ cost,
315
+ );
316
+ }
317
+
318
+ resetFailureCount(providerId);
319
+ });
320
+
321
+ // ── ToS notice on first use ────────────────────────────────
322
+ if (tosUrl) {
323
+ let tosShown = false;
324
+ pi.on("model_select", async (_event, ctx) => {
325
+ if (tosShown || ctx.model?.provider !== providerId) return;
326
+ tosShown = true;
327
+ if (config.hasKey) return;
328
+ const cred = ctx.modelRegistry.authStorage.get(providerId);
329
+ if (cred?.type === "oauth") return;
330
+ ctx.ui.notify(
331
+ `Using ${providerId} free models. Set API key for paid access. Terms: ${tosUrl}`,
332
+ "info",
333
+ );
334
+ });
335
+ }
336
+ }