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,150 @@
1
+ /**
2
+ * Usage Tracking Tests
3
+ */
4
+
5
+ import { beforeEach, describe, expect, it } from "vitest";
6
+ import {
7
+ getModelUsage,
8
+ getProviderModelUsage,
9
+ getSessionUsage,
10
+ getTopModels,
11
+ incrementModelRequestCount,
12
+ resetUsageStats,
13
+ } from "../usage/tracking.ts";
14
+
15
+ describe("Usage Tracking", () => {
16
+ beforeEach(() => {
17
+ resetUsageStats();
18
+ });
19
+
20
+ describe("incrementModelRequestCount", () => {
21
+ it("should track model requests", () => {
22
+ incrementModelRequestCount("kilo", "gpt-4", 100, 50);
23
+
24
+ const usage = getModelUsage("kilo", "gpt-4");
25
+ expect(usage).toBeDefined();
26
+ expect(usage?.count).toBe(1);
27
+ expect(usage?.tokensIn).toBe(100);
28
+ expect(usage?.tokensOut).toBe(50);
29
+ });
30
+
31
+ it("should accumulate multiple requests", () => {
32
+ incrementModelRequestCount("kilo", "gpt-4", 100, 50);
33
+ incrementModelRequestCount("kilo", "gpt-4", 200, 100);
34
+
35
+ const usage = getModelUsage("kilo", "gpt-4");
36
+ expect(usage?.count).toBe(2);
37
+ expect(usage?.tokensIn).toBe(300);
38
+ expect(usage?.tokensOut).toBe(150);
39
+ });
40
+
41
+ it("should track different models separately", () => {
42
+ incrementModelRequestCount("kilo", "gpt-4", 100, 50);
43
+ incrementModelRequestCount("kilo", "claude-3", 200, 100);
44
+
45
+ expect(getModelUsage("kilo", "gpt-4")?.count).toBe(1);
46
+ expect(getModelUsage("kilo", "claude-3")?.count).toBe(1);
47
+ });
48
+
49
+ it("should track different providers separately", () => {
50
+ incrementModelRequestCount("kilo", "gpt-4", 100, 50);
51
+ incrementModelRequestCount("openrouter", "gpt-4", 200, 100);
52
+
53
+ expect(getModelUsage("kilo", "gpt-4")?.count).toBe(1);
54
+ expect(getModelUsage("openrouter", "gpt-4")?.count).toBe(1);
55
+ });
56
+ });
57
+
58
+ describe("getProviderModelUsage", () => {
59
+ it("should return all models for provider", () => {
60
+ incrementModelRequestCount("kilo", "model-a", 100, 50);
61
+ incrementModelRequestCount("kilo", "model-b", 200, 100);
62
+ incrementModelRequestCount("openrouter", "model-c", 300, 150);
63
+
64
+ const kiloModels = getProviderModelUsage("kilo");
65
+ expect(kiloModels).toHaveLength(2);
66
+ expect(kiloModels.map((m) => m.modelId)).toContain("model-a");
67
+ expect(kiloModels.map((m) => m.modelId)).toContain("model-b");
68
+ });
69
+
70
+ it("should sort by count descending", () => {
71
+ incrementModelRequestCount("kilo", "popular", 100, 50);
72
+ incrementModelRequestCount("kilo", "popular", 100, 50);
73
+ incrementModelRequestCount("kilo", "popular", 100, 50);
74
+ incrementModelRequestCount("kilo", "unpopular", 100, 50);
75
+
76
+ const models = getProviderModelUsage("kilo");
77
+ expect(models[0].modelId).toBe("popular");
78
+ expect(models[0].count).toBe(3);
79
+ });
80
+ });
81
+
82
+ describe("getTopModels", () => {
83
+ it("should return top N models across providers", () => {
84
+ // Add many models
85
+ for (let i = 0; i < 5; i++) {
86
+ incrementModelRequestCount("kilo", `kilo-model-${i}`, 100, 50);
87
+ }
88
+ for (let i = 0; i < 5; i++) {
89
+ incrementModelRequestCount("openrouter", `or-model-${i}`, 100, 50);
90
+ }
91
+
92
+ const top5 = getTopModels(5);
93
+ expect(top5).toHaveLength(5);
94
+ });
95
+
96
+ it("should sort by total count", () => {
97
+ incrementModelRequestCount("kilo", "high-usage", 100, 50);
98
+ incrementModelRequestCount("kilo", "high-usage", 100, 50);
99
+ incrementModelRequestCount("kilo", "high-usage", 100, 50);
100
+ incrementModelRequestCount("kilo", "low-usage", 100, 50);
101
+
102
+ const top = getTopModels(2);
103
+ expect(top[0].modelId).toBe("high-usage");
104
+ expect(top[0].count).toBe(3);
105
+ });
106
+ });
107
+
108
+ describe("getSessionUsage", () => {
109
+ it("should return session stats", () => {
110
+ incrementModelRequestCount("kilo", "gpt-4", 1000, 500);
111
+ incrementModelRequestCount("openrouter", "claude", 2000, 1000);
112
+
113
+ const session = getSessionUsage();
114
+ expect(session.totalRequests).toBe(2);
115
+ expect(session.totalTokensIn).toBe(3000);
116
+ expect(session.totalTokensOut).toBe(1500);
117
+ expect(session.providers).toHaveLength(2);
118
+ });
119
+
120
+ it("should format duration", () => {
121
+ const session = getSessionUsage();
122
+ expect(session.duration).toBeGreaterThanOrEqual(0);
123
+ expect(typeof session.durationFormatted).toBe("string");
124
+ });
125
+
126
+ it("should sort providers by request count", () => {
127
+ incrementModelRequestCount("kilo", "model", 100, 50);
128
+ incrementModelRequestCount("kilo", "model", 100, 50);
129
+ incrementModelRequestCount("kilo", "model", 100, 50);
130
+ incrementModelRequestCount("openrouter", "model", 100, 50);
131
+
132
+ const session = getSessionUsage();
133
+ expect(session.providers[0].name).toBe("kilo");
134
+ expect(session.providers[0].requests).toBe(3);
135
+ });
136
+ });
137
+
138
+ describe("resetUsageStats", () => {
139
+ it("should clear all stats", () => {
140
+ incrementModelRequestCount("kilo", "gpt-4", 100, 50);
141
+ resetUsageStats();
142
+
143
+ const usage = getModelUsage("kilo", "gpt-4");
144
+ expect(usage).toBeUndefined();
145
+
146
+ const session = getSessionUsage();
147
+ expect(session.totalRequests).toBe(0);
148
+ });
149
+ });
150
+ });
@@ -0,0 +1,413 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import {
3
+ cleanModelName,
4
+ fetchWithRetry,
5
+ fetchWithTimeout,
6
+ isUsableModel,
7
+ logWarning,
8
+ mapOpenRouterModel,
9
+ parseModelResponse,
10
+ } from "../lib/util.ts";
11
+
12
+ describe("Utility Functions", () => {
13
+ describe("logWarning", () => {
14
+ it("should log warning with provider and message", () => {
15
+ // logWarning now uses lib/logger.ts internally
16
+ // This test verifies it doesn't throw
17
+ expect(() =>
18
+ logWarning("test-provider", "Test warning message"),
19
+ ).not.toThrow();
20
+ });
21
+
22
+ it("should include error details when provided", () => {
23
+ const testError = new Error("Test error");
24
+ expect(() =>
25
+ logWarning("test-provider", "Test warning", testError),
26
+ ).not.toThrow();
27
+ });
28
+ });
29
+
30
+ describe("isUsableModel", () => {
31
+ it("should return true for normal model IDs", () => {
32
+ expect(isUsableModel("gpt-4")).toBe(true);
33
+ expect(isUsableModel("claude-3-opus")).toBe(true);
34
+ expect(isUsableModel("llama-3-70b")).toBe(true);
35
+ });
36
+
37
+ it("should return false for test models", () => {
38
+ expect(isUsableModel("gpt-4-test")).toBe(false);
39
+ expect(isUsableModel("test-model")).toBe(false);
40
+ });
41
+
42
+ it("should return false for debug models", () => {
43
+ expect(isUsableModel("gpt-4-debug")).toBe(false);
44
+ expect(isUsableModel("debug-llama")).toBe(false);
45
+ });
46
+
47
+ it("should handle case variations", () => {
48
+ expect(isUsableModel("GPT-4-Test")).toBe(true); // Case sensitive check
49
+ expect(isUsableModel("model-TEST")).toBe(true);
50
+ });
51
+
52
+ it("should filter by minimum size", () => {
53
+ // 70b model should pass 70B minimum
54
+ expect(isUsableModel("llama-3-70b", 70)).toBe(true);
55
+ // 8b model should fail 70B minimum
56
+ expect(isUsableModel("llama-3-8b", 70)).toBe(false);
57
+ // 405b model should pass
58
+ expect(isUsableModel("llama-3-405b", 70)).toBe(true);
59
+ });
60
+
61
+ it("should handle MoE model sizes", () => {
62
+ // 8x22b = 176b total, should pass 70B
63
+ expect(isUsableModel("mixtral-8x22b", 70)).toBe(true);
64
+ // 8x7b = 56b total, should fail 70B
65
+ expect(isUsableModel("mixtral-8x7b", 70)).toBe(false);
66
+ });
67
+
68
+ it("should skip size filter when minSizeB not provided", () => {
69
+ expect(isUsableModel("tiny-llama")).toBe(true);
70
+ expect(isUsableModel("llama-3-8b")).toBe(true);
71
+ });
72
+ });
73
+
74
+ describe("cleanModelName", () => {
75
+ it("should strip provider prefix with colon", () => {
76
+ expect(cleanModelName("QWEN : Qwen2.5 72B Instruct")).toBe(
77
+ "Qwen2.5 72B Instruct",
78
+ );
79
+ expect(cleanModelName("OpenAI : GPT-4")).toBe("GPT-4");
80
+ expect(cleanModelName("Anthropic : Claude 3 Opus")).toBe("Claude 3 Opus");
81
+ });
82
+
83
+ it("should strip provider prefix with slash", () => {
84
+ expect(cleanModelName("QWEN / Qwen2.5 Coder 32B Instruct")).toBe(
85
+ "Qwen2.5 Coder 32B Instruct",
86
+ );
87
+ expect(cleanModelName("Meta / Llama 3 70B")).toBe("Llama 3 70B");
88
+ });
89
+
90
+ it("should handle varying whitespace around separator", () => {
91
+ expect(cleanModelName("Provider:Model")).toBe("Model");
92
+ expect(cleanModelName("Provider: Model")).toBe("Model");
93
+ expect(cleanModelName("Provider :Model")).toBe("Model");
94
+ expect(cleanModelName("Provider : Model")).toBe("Model");
95
+ });
96
+
97
+ it("should return original name when no separator", () => {
98
+ expect(cleanModelName("GPT-4")).toBe("GPT-4");
99
+ expect(cleanModelName("Claude 3 Opus")).toBe("Claude 3 Opus");
100
+ });
101
+
102
+ it("should trim whitespace", () => {
103
+ expect(cleanModelName(" Model Name ")).toBe("Model Name");
104
+ });
105
+ });
106
+
107
+ describe("mapOpenRouterModel", () => {
108
+ it("should map basic OpenRouter model", () => {
109
+ const input = {
110
+ id: "openai/gpt-4",
111
+ name: "GPT-4",
112
+ context_length: 8192,
113
+ max_completion_tokens: 4096,
114
+ pricing: {
115
+ prompt: "0.03",
116
+ completion: "0.06",
117
+ },
118
+ architecture: {
119
+ input_modalities: ["text"],
120
+ output_modalities: ["text"],
121
+ },
122
+ };
123
+
124
+ const result = mapOpenRouterModel(input);
125
+
126
+ expect(result.id).toBe("openai/gpt-4");
127
+ expect(result.name).toBe("GPT-4");
128
+ expect(result.cost.input).toBe(0.03);
129
+ expect(result.cost.output).toBe(0.06);
130
+ expect(result.cost.cacheRead).toBe(0);
131
+ expect(result.cost.cacheWrite).toBe(0);
132
+ expect(result.contextWindow).toBe(8192);
133
+ expect(result.maxTokens).toBe(4096);
134
+ expect(result.reasoning).toBe(false);
135
+ expect(result.input).toEqual(["text"]);
136
+ });
137
+
138
+ it("should clean provider prefix from model name", () => {
139
+ const input = {
140
+ id: "qwen/qwen-2.5-72b-instruct",
141
+ name: "QWEN : Qwen2.5 72B Instruct",
142
+ context_length: 128000,
143
+ pricing: {
144
+ prompt: "0",
145
+ completion: "0",
146
+ },
147
+ architecture: {
148
+ input_modalities: ["text"],
149
+ output_modalities: ["text"],
150
+ },
151
+ };
152
+
153
+ const result = mapOpenRouterModel(input);
154
+
155
+ expect(result.id).toBe("qwen/qwen-2.5-72b-instruct");
156
+ expect(result.name).toBe("Qwen2.5 72B Instruct");
157
+ });
158
+
159
+ it("should detect image input capability", () => {
160
+ const input = {
161
+ id: "openai/gpt-4-vision",
162
+ name: "GPT-4 Vision",
163
+ context_length: 128000,
164
+ pricing: {
165
+ prompt: "0.01",
166
+ completion: "0.03",
167
+ },
168
+ architecture: {
169
+ input_modalities: ["text", "image"],
170
+ output_modalities: ["text"],
171
+ },
172
+ };
173
+
174
+ const result = mapOpenRouterModel(input);
175
+
176
+ expect(result.input).toEqual(["text", "image"]);
177
+ });
178
+
179
+ it("should handle free models (zero pricing)", () => {
180
+ const input = {
181
+ id: "meta-llama/llama-3.1-8b",
182
+ name: "Llama 3.1 8B",
183
+ context_length: 128000,
184
+ pricing: {
185
+ prompt: "0",
186
+ completion: "0",
187
+ },
188
+ architecture: {
189
+ input_modalities: ["text"],
190
+ output_modalities: ["text"],
191
+ },
192
+ };
193
+
194
+ const result = mapOpenRouterModel(input);
195
+
196
+ expect(result.cost.input).toBe(0);
197
+ expect(result.cost.output).toBe(0);
198
+ });
199
+
200
+ it("should use default values when fields are missing", () => {
201
+ const input = {
202
+ id: "unknown/model",
203
+ name: "Unknown Model",
204
+ };
205
+
206
+ const result = mapOpenRouterModel(
207
+ input as unknown as Parameters<typeof mapOpenRouterModel>[0],
208
+ );
209
+
210
+ expect(result.contextWindow).toBe(4096);
211
+ expect(result.maxTokens).toBe(4096);
212
+ expect(result.cost.input).toBe(0);
213
+ expect(result.cost.output).toBe(0);
214
+ });
215
+
216
+ it("should use top_provider max tokens when available", () => {
217
+ const input = {
218
+ id: "anthropic/claude-3-opus",
219
+ name: "Claude 3 Opus",
220
+ context_length: 200000,
221
+ max_completion_tokens: null,
222
+ top_provider: {
223
+ max_completion_tokens: 4096,
224
+ },
225
+ pricing: {
226
+ prompt: "0.015",
227
+ completion: "0.075",
228
+ },
229
+ architecture: {
230
+ input_modalities: ["text"],
231
+ output_modalities: ["text"],
232
+ },
233
+ };
234
+
235
+ const result = mapOpenRouterModel(input);
236
+
237
+ expect(result.maxTokens).toBe(4096);
238
+ });
239
+ });
240
+
241
+ describe("parseModelResponse", () => {
242
+ it("should parse valid model response", async () => {
243
+ const mockResponse = {
244
+ ok: true,
245
+ json: async () => ({ data: [{ id: "model-1" }, { id: "model-2" }] }),
246
+ } as Response;
247
+
248
+ const result = await parseModelResponse(mockResponse, "test-provider");
249
+
250
+ expect(result.data).toHaveLength(2);
251
+ expect(result.data[0].id).toBe("model-1");
252
+ });
253
+
254
+ it("should throw on non-ok response", async () => {
255
+ const mockResponse = {
256
+ ok: false,
257
+ status: 500,
258
+ statusText: "Internal Server Error",
259
+ } as Response;
260
+
261
+ await expect(
262
+ parseModelResponse(mockResponse, "test-provider"),
263
+ ).rejects.toThrow(
264
+ "Failed to fetch test-provider models: 500 Internal Server Error",
265
+ );
266
+ });
267
+
268
+ it("should throw on missing data array", async () => {
269
+ const mockResponse = {
270
+ ok: true,
271
+ json: async () => ({ models: [] }), // Wrong property name
272
+ } as Response;
273
+
274
+ await expect(
275
+ parseModelResponse(mockResponse, "test-provider"),
276
+ ).rejects.toThrow(
277
+ "Invalid test-provider models response: missing data array",
278
+ );
279
+ });
280
+
281
+ it("should throw on non-array data", async () => {
282
+ const mockResponse = {
283
+ ok: true,
284
+ json: async () => ({ data: "not-an-array" }),
285
+ } as Response;
286
+
287
+ await expect(
288
+ parseModelResponse(mockResponse, "test-provider"),
289
+ ).rejects.toThrow(
290
+ "Invalid test-provider models response: missing data array",
291
+ );
292
+ });
293
+ });
294
+
295
+ describe("fetchWithTimeout", () => {
296
+ it("should fetch successfully within timeout", async () => {
297
+ // Mock fetch to return immediately
298
+ global.fetch = vi.fn().mockResolvedValue({
299
+ ok: true,
300
+ status: 200,
301
+ } as Response);
302
+
303
+ const result = await fetchWithTimeout(
304
+ "https://api.example.com/data",
305
+ {},
306
+ 5000,
307
+ );
308
+
309
+ expect(result.ok).toBe(true);
310
+ });
311
+
312
+ it("should pass headers and options to fetch", async () => {
313
+ const fetchMock = vi.fn().mockResolvedValue({
314
+ ok: true,
315
+ status: 200,
316
+ } as Response);
317
+ global.fetch = fetchMock;
318
+
319
+ await fetchWithTimeout(
320
+ "https://api.example.com/data",
321
+ {
322
+ headers: { Authorization: "Bearer token" },
323
+ method: "POST",
324
+ },
325
+ 5000,
326
+ );
327
+
328
+ expect(fetchMock).toHaveBeenCalledWith(
329
+ "https://api.example.com/data",
330
+ expect.objectContaining({
331
+ headers: { Authorization: "Bearer token" },
332
+ method: "POST",
333
+ signal: expect.any(AbortSignal),
334
+ }),
335
+ );
336
+ });
337
+ });
338
+
339
+ describe("fetchWithRetry", () => {
340
+ it("should succeed on first attempt", async () => {
341
+ global.fetch = vi.fn().mockResolvedValue({
342
+ ok: true,
343
+ status: 200,
344
+ } as Response);
345
+
346
+ const result = await fetchWithRetry("https://api.example.com/data", {});
347
+
348
+ expect(result.ok).toBe(true);
349
+ expect(global.fetch).toHaveBeenCalledTimes(1);
350
+ });
351
+
352
+ it("should retry on server error (5xx)", async () => {
353
+ global.fetch = vi
354
+ .fn()
355
+ .mockResolvedValueOnce({
356
+ ok: false,
357
+ status: 503,
358
+ } as Response)
359
+ .mockResolvedValueOnce({
360
+ ok: true,
361
+ status: 200,
362
+ } as Response);
363
+
364
+ const result = await fetchWithRetry(
365
+ "https://api.example.com/data",
366
+ {},
367
+ 3,
368
+ 100,
369
+ );
370
+
371
+ expect(result.ok).toBe(true);
372
+ expect(global.fetch).toHaveBeenCalledTimes(2);
373
+ });
374
+
375
+ it("should throw immediately on 429 rate limit", async () => {
376
+ global.fetch = vi.fn().mockResolvedValue({
377
+ ok: false,
378
+ status: 429,
379
+ } as Response);
380
+
381
+ await expect(
382
+ fetchWithRetry("https://api.example.com/data", {}),
383
+ ).rejects.toThrow("Rate limited (429)");
384
+ });
385
+
386
+ it("should throw after max retries", async () => {
387
+ global.fetch = vi.fn().mockResolvedValue({
388
+ ok: false,
389
+ status: 500,
390
+ } as Response);
391
+
392
+ await expect(
393
+ fetchWithRetry("https://api.example.com/data", {}, 2, 50),
394
+ ).rejects.toThrow();
395
+
396
+ expect(global.fetch).toHaveBeenCalledTimes(2);
397
+ });
398
+
399
+ it("should return non-retryable error response", async () => {
400
+ // 400 Bad Request - should not retry
401
+ global.fetch = vi.fn().mockResolvedValue({
402
+ ok: false,
403
+ status: 400,
404
+ } as Response);
405
+
406
+ const result = await fetchWithRetry("https://api.example.com/data", {});
407
+
408
+ expect(result.ok).toBe(false);
409
+ expect(result.status).toBe(400);
410
+ expect(global.fetch).toHaveBeenCalledTimes(1);
411
+ });
412
+ });
413
+ });