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.
- package/.github/workflows/update-benchmarks.yml +67 -0
- package/.pi/skills/pi-extension-dev/SKILL.md +155 -0
- package/CHANGELOG.md +59 -0
- package/LICENSE +21 -0
- package/README.md +289 -0
- package/config.ts +224 -0
- package/constants.ts +110 -0
- package/docs/free-tier-limits.md +213 -0
- package/docs/model-hopping.md +214 -0
- package/docs/plans/file-reorganization.md +172 -0
- package/docs/plans/package-json-fix.md +143 -0
- package/docs/provider-failover-plan.md +279 -0
- package/lib/json-persistence.ts +102 -0
- package/lib/logger.ts +94 -0
- package/lib/model-enhancer.ts +20 -0
- package/lib/types.ts +108 -0
- package/lib/util.ts +256 -0
- package/package.json +52 -0
- package/provider-factory.ts +221 -0
- package/provider-failover/errors.ts +275 -0
- package/provider-failover/hardcoded-benchmarks.ts +9889 -0
- package/provider-failover/index.ts +194 -0
- package/provider-helper.ts +336 -0
- package/providers/cline-auth.ts +473 -0
- package/providers/cline-models.ts +77 -0
- package/providers/cline.ts +257 -0
- package/providers/factory.ts +125 -0
- package/providers/fireworks.ts +49 -0
- package/providers/kilo-auth.ts +172 -0
- package/providers/kilo-models.ts +26 -0
- package/providers/kilo.ts +144 -0
- package/providers/mistral.ts +144 -0
- package/providers/model-fetcher.ts +138 -0
- package/providers/nvidia.ts +97 -0
- package/providers/ollama.ts +113 -0
- package/providers/openrouter.ts +175 -0
- package/providers/zen.ts +416 -0
- package/scripts/update-benchmarks.ts +255 -0
- package/tests/cline.test.ts +149 -0
- package/tests/errors.test.ts +139 -0
- package/tests/failover.test.ts +94 -0
- package/tests/fireworks.test.ts +148 -0
- package/tests/free-tier-limits.test.ts +191 -0
- package/tests/json-persistence.test.ts +105 -0
- package/tests/kilo.test.ts +186 -0
- package/tests/mistral.test.ts +138 -0
- package/tests/nvidia.test.ts +55 -0
- package/tests/ollama.test.ts +261 -0
- package/tests/openrouter.test.ts +192 -0
- package/tests/usage-tracking.test.ts +150 -0
- package/tests/util.test.ts +413 -0
- package/tests/zen.test.ts +180 -0
- package/todo.md +153 -0
- package/tsconfig.json +26 -0
- package/usage/commands.ts +17 -0
- package/usage/cumulative.ts +193 -0
- package/usage/formatters.ts +131 -0
- package/usage/index.ts +46 -0
- package/usage/limits.ts +166 -0
- package/usage/metrics.ts +222 -0
- package/usage/sessions.ts +355 -0
- package/usage/store.ts +99 -0
- package/usage/tracking.ts +329 -0
- package/usage/widget.ts +90 -0
- package/vitest.config.ts +20 -0
- package/widget/data.ts +113 -0
- package/widget/format.ts +26 -0
- package/widget/render.ts +117 -0
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Zen Provider Tests
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type {
|
|
6
|
+
ExtensionAPI,
|
|
7
|
+
ProviderModelConfig,
|
|
8
|
+
} from "@mariozechner/pi-coding-agent";
|
|
9
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
10
|
+
|
|
11
|
+
// Mock dependencies
|
|
12
|
+
vi.mock("../config.ts", () => ({
|
|
13
|
+
OPENCODE_API_KEY: "test-key",
|
|
14
|
+
ZEN_SHOW_PAID: false,
|
|
15
|
+
PROVIDER_ZEN: "zen",
|
|
16
|
+
applyHidden: (models: ProviderModelConfig[]) => models,
|
|
17
|
+
}));
|
|
18
|
+
|
|
19
|
+
vi.mock("../constants.ts", () => ({
|
|
20
|
+
BASE_URL_ZEN: "https://opencode.ai/zen/v1",
|
|
21
|
+
DEFAULT_FETCH_TIMEOUT_MS: 10000,
|
|
22
|
+
URL_MODELS_DEV: "https://models.dev/api.json",
|
|
23
|
+
URL_ZEN_TOS: "https://opencode.ai/terms",
|
|
24
|
+
}));
|
|
25
|
+
|
|
26
|
+
vi.mock("../provider-helper.ts", () => ({
|
|
27
|
+
createReRegister: vi.fn(() => vi.fn()),
|
|
28
|
+
createCtxReRegister: vi.fn(() => vi.fn()),
|
|
29
|
+
setupProvider: vi.fn(),
|
|
30
|
+
}));
|
|
31
|
+
|
|
32
|
+
vi.mock("../util.ts", () => ({
|
|
33
|
+
fetchWithRetry: vi.fn(),
|
|
34
|
+
logWarning: vi.fn(),
|
|
35
|
+
}));
|
|
36
|
+
|
|
37
|
+
import { fetchWithRetry } from "../lib/util.ts";
|
|
38
|
+
import { setupProvider } from "../provider-helper.ts";
|
|
39
|
+
import zenProvider from "../providers/zen.ts";
|
|
40
|
+
|
|
41
|
+
describe("Zen Provider", () => {
|
|
42
|
+
let mockPi: ExtensionAPI;
|
|
43
|
+
let mockOn: ReturnType<typeof vi.fn>;
|
|
44
|
+
|
|
45
|
+
beforeEach(() => {
|
|
46
|
+
vi.clearAllMocks();
|
|
47
|
+
mockOn = vi.fn();
|
|
48
|
+
|
|
49
|
+
mockPi = {
|
|
50
|
+
on: mockOn,
|
|
51
|
+
} as unknown as ExtensionAPI;
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
describe("initialization", () => {
|
|
55
|
+
it("should register event handlers", async () => {
|
|
56
|
+
vi.mocked(fetchWithRetry).mockResolvedValue({
|
|
57
|
+
ok: true,
|
|
58
|
+
json: vi.fn().mockResolvedValue({ data: [{ id: "test-model" }] }),
|
|
59
|
+
} as unknown as Response);
|
|
60
|
+
|
|
61
|
+
await zenProvider(mockPi);
|
|
62
|
+
|
|
63
|
+
expect(mockOn).toHaveBeenCalledWith(
|
|
64
|
+
"session_start",
|
|
65
|
+
expect.any(Function),
|
|
66
|
+
);
|
|
67
|
+
expect(mockOn).toHaveBeenCalledWith(
|
|
68
|
+
"before_agent_start",
|
|
69
|
+
expect.any(Function),
|
|
70
|
+
);
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
describe("session_start handling", () => {
|
|
75
|
+
it("should fetch and register models", async () => {
|
|
76
|
+
const gatewayModels = {
|
|
77
|
+
data: [{ id: "big-pickle" }, { id: "mimo-v2-pro-free" }],
|
|
78
|
+
};
|
|
79
|
+
const modelsDevResponse = {
|
|
80
|
+
opencode: {
|
|
81
|
+
id: "opencode",
|
|
82
|
+
models: {
|
|
83
|
+
"big-pickle": {
|
|
84
|
+
id: "big-pickle",
|
|
85
|
+
name: "Big Pickle",
|
|
86
|
+
reasoning: true,
|
|
87
|
+
cost: { input: 0, output: 0 },
|
|
88
|
+
limit: { context: 200000, output: 128000 },
|
|
89
|
+
modalities: { input: ["text"] },
|
|
90
|
+
},
|
|
91
|
+
},
|
|
92
|
+
},
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
vi.mocked(fetchWithRetry)
|
|
96
|
+
.mockResolvedValueOnce({
|
|
97
|
+
ok: true,
|
|
98
|
+
json: vi.fn().mockResolvedValue(gatewayModels),
|
|
99
|
+
} as unknown as Response)
|
|
100
|
+
.mockResolvedValueOnce({
|
|
101
|
+
ok: true,
|
|
102
|
+
json: vi.fn().mockResolvedValue(modelsDevResponse),
|
|
103
|
+
} as unknown as Response);
|
|
104
|
+
|
|
105
|
+
await zenProvider(mockPi);
|
|
106
|
+
|
|
107
|
+
const sessionStartHandler = mockOn.mock.calls.find(
|
|
108
|
+
(call) => call[0] === "session_start",
|
|
109
|
+
)?.[1];
|
|
110
|
+
|
|
111
|
+
const mockRegisterProvider = vi.fn();
|
|
112
|
+
const mockCtx = {
|
|
113
|
+
modelRegistry: {
|
|
114
|
+
getAvailable: vi.fn().mockReturnValue([]),
|
|
115
|
+
registerProvider: mockRegisterProvider,
|
|
116
|
+
},
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
await sessionStartHandler({}, mockCtx);
|
|
120
|
+
|
|
121
|
+
expect(mockRegisterProvider).toHaveBeenCalledWith(
|
|
122
|
+
"zen",
|
|
123
|
+
expect.objectContaining({
|
|
124
|
+
baseUrl: "https://opencode.ai/zen/v1",
|
|
125
|
+
apiKey: "PI_FREE_ZEN_API_KEY",
|
|
126
|
+
headers: expect.objectContaining({
|
|
127
|
+
"X-Title": "Pi",
|
|
128
|
+
"x-opencode-session": expect.any(String),
|
|
129
|
+
}),
|
|
130
|
+
}),
|
|
131
|
+
);
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
describe("fallback behavior", () => {
|
|
136
|
+
it("should use static fallback when API fails", async () => {
|
|
137
|
+
vi.mocked(fetchWithRetry).mockRejectedValue(new Error("Network error"));
|
|
138
|
+
|
|
139
|
+
await zenProvider(mockPi);
|
|
140
|
+
|
|
141
|
+
const sessionStartHandler = mockOn.mock.calls.find(
|
|
142
|
+
(call) => call[0] === "session_start",
|
|
143
|
+
)?.[1];
|
|
144
|
+
|
|
145
|
+
const mockRegisterProvider = vi.fn();
|
|
146
|
+
const mockCtx = {
|
|
147
|
+
modelRegistry: {
|
|
148
|
+
getAvailable: vi.fn().mockReturnValue([]),
|
|
149
|
+
registerProvider: mockRegisterProvider,
|
|
150
|
+
},
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
await sessionStartHandler({}, mockCtx);
|
|
154
|
+
|
|
155
|
+
// When API fails, it should not register (let Pi use built-in)
|
|
156
|
+
expect(mockRegisterProvider).not.toHaveBeenCalled();
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
describe("setupProvider integration", () => {
|
|
161
|
+
it("should call setupProvider with correct config", async () => {
|
|
162
|
+
vi.mocked(fetchWithRetry).mockResolvedValue({
|
|
163
|
+
ok: true,
|
|
164
|
+
json: vi.fn().mockResolvedValue({ data: [] }),
|
|
165
|
+
} as unknown as Response);
|
|
166
|
+
|
|
167
|
+
await zenProvider(mockPi);
|
|
168
|
+
|
|
169
|
+
expect(setupProvider).toHaveBeenCalledWith(
|
|
170
|
+
mockPi,
|
|
171
|
+
expect.objectContaining({
|
|
172
|
+
providerId: "zen",
|
|
173
|
+
tosUrl: "https://opencode.ai/terms",
|
|
174
|
+
hasKey: true,
|
|
175
|
+
}),
|
|
176
|
+
expect.any(Object),
|
|
177
|
+
);
|
|
178
|
+
});
|
|
179
|
+
});
|
|
180
|
+
});
|
package/todo.md
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
# TODO: Fix Usage Tracking & Rate Limit Attribution
|
|
2
|
+
|
|
3
|
+
## Current State
|
|
4
|
+
|
|
5
|
+
The usage tracking infrastructure exists but is **not fully functional**:
|
|
6
|
+
|
|
7
|
+
1. **Usage commands are disabled** (`usage/commands.ts`)
|
|
8
|
+
- `/free-sessionusage` and `/free-totalusage` commands exist but are commented out
|
|
9
|
+
- Reason: "duplicate registration issues" across providers
|
|
10
|
+
|
|
11
|
+
2. **Usage tracking works at runtime** (`usage/tracking.ts`)
|
|
12
|
+
- Per-model request counts tracked in `modelUsageCounts` Map
|
|
13
|
+
- Session stats tracked in `sessionStats` Map
|
|
14
|
+
- Called from `provider-helper.ts` on each `turn_end` event
|
|
15
|
+
|
|
16
|
+
3. **Cumulative usage persists** (`usage/cumulative.ts`, `usage/store.ts`)
|
|
17
|
+
- Stored at `~/.pi/free-usage.json`
|
|
18
|
+
- Tracks all-time usage per provider/model
|
|
19
|
+
|
|
20
|
+
4. **Free tier limits defined** (`free-tier-limits.ts`)
|
|
21
|
+
- Hardcoded limits per provider
|
|
22
|
+
- NOT correctly integrated with usage tracking
|
|
23
|
+
|
|
24
|
+
## Problems to Fix
|
|
25
|
+
|
|
26
|
+
### 1. Usage Commands Disabled
|
|
27
|
+
**File:** `usage/commands.ts`
|
|
28
|
+
|
|
29
|
+
The `registerUsageCommands()` function is empty. The slash commands need to:
|
|
30
|
+
- Register once globally (not per-provider)
|
|
31
|
+
- Show current session usage with rate limit status
|
|
32
|
+
- Show cumulative all-time usage
|
|
33
|
+
|
|
34
|
+
**Current:**
|
|
35
|
+
```typescript
|
|
36
|
+
export function registerUsageCommands(_pi: ExtensionAPI): void {
|
|
37
|
+
// Commands disabled - Pi shows duplicate registrations across providers
|
|
38
|
+
// TODO: Find reliable way to register global commands once
|
|
39
|
+
}
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
**Needed:**
|
|
43
|
+
- Register `/free-sessionusage` - shows current session breakdown
|
|
44
|
+
- Register `/free-totalusage` - shows cumulative usage from disk
|
|
45
|
+
- Include rate limit warnings (🟢🟡🔴) based on current usage vs limits
|
|
46
|
+
|
|
47
|
+
### 2. Rate Limit Attribution Wrong
|
|
48
|
+
**Files:** `usage/tracking.ts`, `free-tier-limits.ts`, `usage/formatters.ts`
|
|
49
|
+
|
|
50
|
+
The `FREE_TIER_LIMITS` in `free-tier-limits.ts` defines limits, but:
|
|
51
|
+
- Zen has no entry (has free tier but not in the list)
|
|
52
|
+
- Ollama has no entry (has free tier but not in the list)
|
|
53
|
+
- Fireworks has no entry (has free tier but not in the list)
|
|
54
|
+
- Mistral has no entry (has free tier but not in the list)
|
|
55
|
+
- Cline has no entry (has free tier but not in the list)
|
|
56
|
+
|
|
57
|
+
**Current `FREE_TIER_LIMITS`:**
|
|
58
|
+
```typescript
|
|
59
|
+
export const FREE_TIER_LIMITS: Record<string, FreeTierLimit> = {
|
|
60
|
+
kilo: { provider: "kilo", requestsPerHour: 200, ... },
|
|
61
|
+
openrouter: { provider: "openrouter", requestsPerDay: 1000, ... },
|
|
62
|
+
nvidia: { provider: "nvidia", requestsPerMonth: 1000, ... },
|
|
63
|
+
// Missing: zen, ollama, fireworks, mistral, cline
|
|
64
|
+
};
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
**Needed:**
|
|
68
|
+
- Add all providers with free tiers
|
|
69
|
+
- Research actual limits from provider docs
|
|
70
|
+
- Track per-provider usage counts separately (currently aggregated in `metrics.ts`)
|
|
71
|
+
|
|
72
|
+
### 3. Usage Formatters Not Integrated
|
|
73
|
+
**File:** `usage/formatters.ts`
|
|
74
|
+
|
|
75
|
+
The formatters exist but are not called anywhere:
|
|
76
|
+
- `formatSessionUsage()` - formats session stats
|
|
77
|
+
- `formatCumulativeUsage()` - formats cumulative stats
|
|
78
|
+
- `formatFreeTierStatus()` - formats rate limit status with 🟢🟡🔴
|
|
79
|
+
|
|
80
|
+
**Needed:**
|
|
81
|
+
- Call formatters from usage commands
|
|
82
|
+
- Show rate limit warnings based on `FREE_TIER_LIMITS`
|
|
83
|
+
- Calculate percentage used: `(currentUsage / limit) * 100`
|
|
84
|
+
|
|
85
|
+
### 4. Provider-Level Usage Tracking Missing
|
|
86
|
+
**File:** `usage/tracking.ts`
|
|
87
|
+
|
|
88
|
+
Current tracking aggregates by model, but doesn't track per-provider totals correctly for rate limits.
|
|
89
|
+
|
|
90
|
+
The `sessionStats.providers` Map exists but:
|
|
91
|
+
- Doesn't reset per time window (hour/day/month)
|
|
92
|
+
- Doesn't check against `FREE_TIER_LIMITS`
|
|
93
|
+
|
|
94
|
+
**Needed:**
|
|
95
|
+
- Track usage per time window (hourly for Kilo, daily for OpenRouter, monthly for NVIDIA)
|
|
96
|
+
- Check limits before incrementing (warn at 80%, critical at 95%)
|
|
97
|
+
- Show warning in UI when approaching limits
|
|
98
|
+
|
|
99
|
+
## Implementation Plan
|
|
100
|
+
|
|
101
|
+
### Phase 1: Fix Free Tier Limits
|
|
102
|
+
- [ ] Add missing providers to `FREE_TIER_LIMITS`
|
|
103
|
+
- [ ] Zen: 1000/day (verify with opencode.ai docs)
|
|
104
|
+
- [ ] Ollama: 5 hours + 7 days reset (explain credit system)
|
|
105
|
+
- [ ] Fireworks: research free tier limits
|
|
106
|
+
- [ ] Mistral: research free tier limits
|
|
107
|
+
- [ ] Cline: research free tier limits
|
|
108
|
+
|
|
109
|
+
### Phase 2: Wire Up Usage Commands
|
|
110
|
+
- [ ] Create single registration point for global commands
|
|
111
|
+
- [ ] Register `/free-sessionusage` command
|
|
112
|
+
- [ ] Register `/free-totalusage` command
|
|
113
|
+
- [ ] Test commands don't duplicate across providers
|
|
114
|
+
|
|
115
|
+
### Phase 3: Integrate Formatters
|
|
116
|
+
- [ ] Call `formatSessionUsage()` in `/free-sessionusage`
|
|
117
|
+
- [ ] Call `formatCumulativeUsage()` in `/free-totalusage`
|
|
118
|
+
- [ ] Add rate limit status to both commands using `formatFreeTierStatus()`
|
|
119
|
+
|
|
120
|
+
### Phase 4: Per-Provider Rate Limit Warnings
|
|
121
|
+
- [ ] Track usage per time window (hour/day/month)
|
|
122
|
+
- [ ] Add limit checking in `incrementRequestCount()`
|
|
123
|
+
- [ ] Show UI notification when approaching limit (80% = 🟡, 95% = 🔴)
|
|
124
|
+
- [ ] Reset counters when time window expires
|
|
125
|
+
|
|
126
|
+
## Provider Free Tier Research
|
|
127
|
+
|
|
128
|
+
| Provider | Free Tier | Rate Limit | Documentation |
|
|
129
|
+
|----------|-----------|------------|---------------|
|
|
130
|
+
| Kilo | 14 models | 200 req/hour | kilo.ai/terms |
|
|
131
|
+
| Zen | 11 models | 1000 req/day | opencode.ai |
|
|
132
|
+
| OpenRouter | 29 models | 1000 req/day | openrouter.ai/docs |
|
|
133
|
+
| NVIDIA | Curated 70B+ | 1000 credits/mo | build.nvidia.com |
|
|
134
|
+
| Ollama | Cloud models | 5hrs + 7 days reset | ollama.com |
|
|
135
|
+
| Fireworks | ? | ? | app.fireworks.ai |
|
|
136
|
+
| Mistral | ? | ? | mistral.ai |
|
|
137
|
+
| Cline | ? | ? | cline.bot |
|
|
138
|
+
|
|
139
|
+
## Files to Modify
|
|
140
|
+
|
|
141
|
+
1. `free-tier-limits.ts` - Add missing providers, verify limits
|
|
142
|
+
2. `usage/commands.ts` - Implement command registration
|
|
143
|
+
3. `usage/formatters.ts` - Ensure formatters use correct limits
|
|
144
|
+
4. `usage/tracking.ts` - Add per-time-window tracking
|
|
145
|
+
5. `provider-helper.ts` - Call limit check on turn_end
|
|
146
|
+
6. `README.md` - Document usage commands once working
|
|
147
|
+
|
|
148
|
+
## Notes
|
|
149
|
+
|
|
150
|
+
- The `metrics.ts` file has `getDailyRequestCount()` but it tracks ALL providers together
|
|
151
|
+
- Need separate counters per provider per time window
|
|
152
|
+
- Consider using `usage/sessions.ts` for session-level tracking
|
|
153
|
+
- The cumulative storage in `usage/store.ts` works - don't break it
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "NodeNext",
|
|
5
|
+
"moduleResolution": "NodeNext",
|
|
6
|
+
"lib": ["ES2022"],
|
|
7
|
+
"strict": true,
|
|
8
|
+
"esModuleInterop": true,
|
|
9
|
+
"skipLibCheck": true,
|
|
10
|
+
"forceConsistentCasingInFileNames": true,
|
|
11
|
+
"resolveJsonModule": true,
|
|
12
|
+
"declaration": true,
|
|
13
|
+
"declarationMap": true,
|
|
14
|
+
"sourceMap": true,
|
|
15
|
+
"allowImportingTsExtensions": true,
|
|
16
|
+
"noEmit": true,
|
|
17
|
+
"baseUrl": ".",
|
|
18
|
+
"paths": {
|
|
19
|
+
"@mariozechner/pi-coding-agent": [
|
|
20
|
+
"node_modules/@mariozechner/pi-coding-agent"
|
|
21
|
+
]
|
|
22
|
+
}
|
|
23
|
+
},
|
|
24
|
+
"include": ["**/*.ts", "providers/**/*.ts"],
|
|
25
|
+
"exclude": ["node_modules", ".pi-lens"]
|
|
26
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Free tier usage commands
|
|
3
|
+
*
|
|
4
|
+
* Provides:
|
|
5
|
+
* - /free-sessionusage: Current session breakdown
|
|
6
|
+
* - /free-totalusage: Cumulative usage from disk
|
|
7
|
+
*
|
|
8
|
+
* NOTE: Commands temporarily disabled due to duplicate registration issues.
|
|
9
|
+
* Use /kilo-sessionusage or /zen-sessionusage instead.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
13
|
+
|
|
14
|
+
export function registerUsageCommands(_pi: ExtensionAPI): void {
|
|
15
|
+
// Commands disabled - Pi shows duplicate registrations across providers
|
|
16
|
+
// TODO: Find reliable way to register global commands once
|
|
17
|
+
}
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cumulative usage persistence - disk storage for all-time stats
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { createJSONStore } from "../lib/json-persistence.ts";
|
|
7
|
+
import { createLogger } from "../lib/logger.ts";
|
|
8
|
+
|
|
9
|
+
const _logger = createLogger("usage:cumulative");
|
|
10
|
+
|
|
11
|
+
const PI_DIR = join(process.env.HOME || process.env.USERPROFILE || "", ".pi");
|
|
12
|
+
const USAGE_FILE = join(PI_DIR, "free-cumulative-usage.json");
|
|
13
|
+
|
|
14
|
+
interface CumulativeProviderStats {
|
|
15
|
+
totalRequests: number;
|
|
16
|
+
totalTokensIn: number;
|
|
17
|
+
totalTokensOut: number;
|
|
18
|
+
totalCacheRead: number;
|
|
19
|
+
totalCacheWrite: number;
|
|
20
|
+
totalCost: number;
|
|
21
|
+
models: Record<
|
|
22
|
+
string,
|
|
23
|
+
{
|
|
24
|
+
count: number;
|
|
25
|
+
tokensIn: number;
|
|
26
|
+
tokensOut: number;
|
|
27
|
+
cacheRead: number;
|
|
28
|
+
cacheWrite: number;
|
|
29
|
+
cost: number;
|
|
30
|
+
}
|
|
31
|
+
>;
|
|
32
|
+
firstUsed: string;
|
|
33
|
+
lastUsed: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
interface CumulativeUsage {
|
|
37
|
+
providers: Record<string, CumulativeProviderStats>;
|
|
38
|
+
grandTotalRequests: number;
|
|
39
|
+
grandTotalTokensIn: number;
|
|
40
|
+
grandTotalTokensOut: number;
|
|
41
|
+
grandTotalCacheRead: number;
|
|
42
|
+
grandTotalCacheWrite: number;
|
|
43
|
+
grandTotalCost: number;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const cumulativeStore = createJSONStore<CumulativeUsage>(USAGE_FILE, {
|
|
47
|
+
providers: {},
|
|
48
|
+
grandTotalRequests: 0,
|
|
49
|
+
grandTotalTokensIn: 0,
|
|
50
|
+
grandTotalTokensOut: 0,
|
|
51
|
+
grandTotalCacheRead: 0,
|
|
52
|
+
grandTotalCacheWrite: 0,
|
|
53
|
+
grandTotalCost: 0,
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
export function persistUsage(
|
|
57
|
+
provider: string,
|
|
58
|
+
modelId: string,
|
|
59
|
+
tokensIn: number,
|
|
60
|
+
tokensOut: number,
|
|
61
|
+
cacheRead: number,
|
|
62
|
+
cacheWrite: number,
|
|
63
|
+
cost: number,
|
|
64
|
+
): void {
|
|
65
|
+
const data = cumulativeStore.load();
|
|
66
|
+
const now = new Date().toISOString();
|
|
67
|
+
|
|
68
|
+
let providerStats = data.providers[provider];
|
|
69
|
+
if (!providerStats) {
|
|
70
|
+
providerStats = {
|
|
71
|
+
totalRequests: 0,
|
|
72
|
+
totalTokensIn: 0,
|
|
73
|
+
totalTokensOut: 0,
|
|
74
|
+
totalCacheRead: 0,
|
|
75
|
+
totalCacheWrite: 0,
|
|
76
|
+
totalCost: 0,
|
|
77
|
+
models: {},
|
|
78
|
+
firstUsed: now,
|
|
79
|
+
lastUsed: now,
|
|
80
|
+
};
|
|
81
|
+
data.providers[provider] = providerStats;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
providerStats.totalRequests++;
|
|
85
|
+
providerStats.totalTokensIn += tokensIn;
|
|
86
|
+
providerStats.totalTokensOut += tokensOut;
|
|
87
|
+
providerStats.totalCacheRead += cacheRead;
|
|
88
|
+
providerStats.totalCacheWrite += cacheWrite;
|
|
89
|
+
providerStats.totalCost += cost;
|
|
90
|
+
providerStats.lastUsed = now;
|
|
91
|
+
|
|
92
|
+
const modelStats = providerStats.models[modelId] ?? {
|
|
93
|
+
count: 0,
|
|
94
|
+
tokensIn: 0,
|
|
95
|
+
tokensOut: 0,
|
|
96
|
+
cacheRead: 0,
|
|
97
|
+
cacheWrite: 0,
|
|
98
|
+
cost: 0,
|
|
99
|
+
};
|
|
100
|
+
modelStats.count++;
|
|
101
|
+
modelStats.tokensIn += tokensIn;
|
|
102
|
+
modelStats.tokensOut += tokensOut;
|
|
103
|
+
modelStats.cacheRead += cacheRead;
|
|
104
|
+
modelStats.cacheWrite += cacheWrite;
|
|
105
|
+
modelStats.cost += cost;
|
|
106
|
+
providerStats.models[modelId] = modelStats;
|
|
107
|
+
|
|
108
|
+
data.grandTotalRequests++;
|
|
109
|
+
data.grandTotalTokensIn += tokensIn;
|
|
110
|
+
data.grandTotalTokensOut += tokensOut;
|
|
111
|
+
data.grandTotalCacheRead += cacheRead;
|
|
112
|
+
data.grandTotalCacheWrite += cacheWrite;
|
|
113
|
+
data.grandTotalCost += cost;
|
|
114
|
+
|
|
115
|
+
cumulativeStore.save(data);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export interface CumulativeUsageReport {
|
|
119
|
+
providers: Array<{
|
|
120
|
+
name: string;
|
|
121
|
+
totalRequests: number;
|
|
122
|
+
totalTokensIn: number;
|
|
123
|
+
totalTokensOut: number;
|
|
124
|
+
totalCacheRead: number;
|
|
125
|
+
totalCacheWrite: number;
|
|
126
|
+
totalCost: number;
|
|
127
|
+
modelCount: number;
|
|
128
|
+
firstUsed: string;
|
|
129
|
+
lastUsed: string;
|
|
130
|
+
topModels: Array<{
|
|
131
|
+
modelId: string;
|
|
132
|
+
count: number;
|
|
133
|
+
tokensIn: number;
|
|
134
|
+
tokensOut: number;
|
|
135
|
+
cacheRead: number;
|
|
136
|
+
cacheWrite: number;
|
|
137
|
+
cost: number;
|
|
138
|
+
}>;
|
|
139
|
+
}>;
|
|
140
|
+
grandTotalRequests: number;
|
|
141
|
+
grandTotalTokensIn: number;
|
|
142
|
+
grandTotalTokensOut: number;
|
|
143
|
+
grandTotalCacheRead: number;
|
|
144
|
+
grandTotalCacheWrite: number;
|
|
145
|
+
grandTotalCost: number;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export function getCumulativeUsage(): CumulativeUsageReport {
|
|
149
|
+
const data = cumulativeStore.load();
|
|
150
|
+
|
|
151
|
+
const providers: CumulativeUsageReport["providers"] = [];
|
|
152
|
+
|
|
153
|
+
for (const [name, stats] of Object.entries(data.providers)) {
|
|
154
|
+
const topModels = Object.entries(stats.models)
|
|
155
|
+
.map(([modelId, m]) => ({
|
|
156
|
+
modelId,
|
|
157
|
+
count: m.count,
|
|
158
|
+
tokensIn: m.tokensIn,
|
|
159
|
+
tokensOut: m.tokensOut,
|
|
160
|
+
cacheRead: m.cacheRead,
|
|
161
|
+
cacheWrite: m.cacheWrite,
|
|
162
|
+
cost: m.cost,
|
|
163
|
+
}))
|
|
164
|
+
.sort((a, b) => b.count - a.count)
|
|
165
|
+
.slice(0, 5);
|
|
166
|
+
|
|
167
|
+
providers.push({
|
|
168
|
+
name,
|
|
169
|
+
totalRequests: stats.totalRequests,
|
|
170
|
+
totalTokensIn: stats.totalTokensIn,
|
|
171
|
+
totalTokensOut: stats.totalTokensOut,
|
|
172
|
+
totalCacheRead: stats.totalCacheRead,
|
|
173
|
+
totalCacheWrite: stats.totalCacheWrite,
|
|
174
|
+
totalCost: stats.totalCost,
|
|
175
|
+
modelCount: Object.keys(stats.models).length,
|
|
176
|
+
firstUsed: stats.firstUsed,
|
|
177
|
+
lastUsed: stats.lastUsed,
|
|
178
|
+
topModels,
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
providers.sort((a, b) => b.totalRequests - a.totalRequests);
|
|
183
|
+
|
|
184
|
+
return {
|
|
185
|
+
providers,
|
|
186
|
+
grandTotalRequests: data.grandTotalRequests,
|
|
187
|
+
grandTotalTokensIn: data.grandTotalTokensIn,
|
|
188
|
+
grandTotalTokensOut: data.grandTotalTokensOut,
|
|
189
|
+
grandTotalCacheRead: data.grandTotalCacheRead,
|
|
190
|
+
grandTotalCacheWrite: data.grandTotalCacheWrite,
|
|
191
|
+
grandTotalCost: data.grandTotalCost,
|
|
192
|
+
};
|
|
193
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Usage report formatters - text formatting for display
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { CumulativeUsageReport } from "./cumulative.ts";
|
|
6
|
+
import {
|
|
7
|
+
type FreeTierLimit,
|
|
8
|
+
getFreeTierUsage,
|
|
9
|
+
getLimitWarning,
|
|
10
|
+
} from "./limits.ts";
|
|
11
|
+
import type { SessionUsageReport } from "./tracking.ts";
|
|
12
|
+
|
|
13
|
+
export interface FreeTierUsage {
|
|
14
|
+
provider: string;
|
|
15
|
+
requestsToday: number;
|
|
16
|
+
requestsThisHour: number;
|
|
17
|
+
requestsThisMonth?: number;
|
|
18
|
+
limit: FreeTierLimit;
|
|
19
|
+
remainingToday?: number;
|
|
20
|
+
remainingThisHour?: number;
|
|
21
|
+
remainingThisMonth?: number;
|
|
22
|
+
percentUsed: number;
|
|
23
|
+
status: "ok" | "warning" | "critical" | "unknown";
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function formatSessionUsage(report: SessionUsageReport): string {
|
|
27
|
+
if (report.providers.length === 0) {
|
|
28
|
+
return "No usage recorded in this session yet.";
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const lines: string[] = [];
|
|
32
|
+
lines.push("━".repeat(50));
|
|
33
|
+
lines.push(`📊 Session Usage (${report.durationFormatted})`);
|
|
34
|
+
lines.push("━".repeat(50));
|
|
35
|
+
lines.push("");
|
|
36
|
+
|
|
37
|
+
for (const p of report.providers) {
|
|
38
|
+
const warning = getLimitWarning(p.name);
|
|
39
|
+
const statusEmoji = warning ? (warning.includes("⚠️") ? "🔴" : "🟡") : "🟢";
|
|
40
|
+
lines.push(`${statusEmoji} ${p.name}`);
|
|
41
|
+
lines.push(` Requests: ${p.requests}`);
|
|
42
|
+
lines.push(
|
|
43
|
+
` Tokens: ~${Math.round(p.tokensIn / 1000)}K in, ~${Math.round(p.tokensOut / 1000)}K out`,
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
if (p.topModels.length > 0) {
|
|
47
|
+
lines.push(` Top models:`);
|
|
48
|
+
for (const m of p.topModels.slice(0, 3)) {
|
|
49
|
+
lines.push(` • ${m.modelId.split("/").pop()}: ${m.count} req`);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
lines.push("");
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
lines.push("━".repeat(50));
|
|
56
|
+
lines.push(
|
|
57
|
+
`📈 Totals: ${report.totalRequests} requests, ~${Math.round(report.totalTokensIn / 1000)}K tokens`,
|
|
58
|
+
);
|
|
59
|
+
lines.push("━".repeat(50));
|
|
60
|
+
|
|
61
|
+
return lines.join("\n");
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function formatCumulativeUsage(report: CumulativeUsageReport): string {
|
|
65
|
+
if (report.providers.length === 0) {
|
|
66
|
+
return "No cumulative usage data yet. Start using free models!";
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const lines: string[] = [];
|
|
70
|
+
lines.push("━".repeat(50));
|
|
71
|
+
lines.push("📊 Total Usage (All Time)");
|
|
72
|
+
lines.push("━".repeat(50));
|
|
73
|
+
lines.push("");
|
|
74
|
+
|
|
75
|
+
for (const p of report.providers) {
|
|
76
|
+
lines.push(`🔹 ${p.name}`);
|
|
77
|
+
lines.push(` Requests: ${p.totalRequests.toLocaleString()}`);
|
|
78
|
+
lines.push(
|
|
79
|
+
` Tokens: ~${Math.round(p.totalTokensIn / 1000).toLocaleString()}K in, ~${Math.round(p.totalTokensOut / 1000).toLocaleString()}K out`,
|
|
80
|
+
);
|
|
81
|
+
lines.push(` Models used: ${p.modelCount}`);
|
|
82
|
+
|
|
83
|
+
if (p.topModels.length > 0) {
|
|
84
|
+
lines.push(` Top models:`);
|
|
85
|
+
for (const m of p.topModels.slice(0, 3)) {
|
|
86
|
+
lines.push(
|
|
87
|
+
` • ${m.modelId.split("/").pop()}: ${m.count.toLocaleString()} req`,
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
lines.push(` Active since: ${p.firstUsed.split("T")[0]}`);
|
|
93
|
+
lines.push("");
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
lines.push("━".repeat(50));
|
|
97
|
+
lines.push(`📈 Grand Totals:`);
|
|
98
|
+
lines.push(` ${report.grandTotalRequests.toLocaleString()} requests`);
|
|
99
|
+
lines.push(
|
|
100
|
+
` ~${Math.round(report.grandTotalTokensIn / 1000).toLocaleString()}K input tokens`,
|
|
101
|
+
);
|
|
102
|
+
lines.push(
|
|
103
|
+
` ~${Math.round(report.grandTotalTokensOut / 1000).toLocaleString()}K output tokens`,
|
|
104
|
+
);
|
|
105
|
+
lines.push("━".repeat(50));
|
|
106
|
+
|
|
107
|
+
return lines.join("\n");
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function formatFreeTierStatus(provider: string): string {
|
|
111
|
+
const usage = getFreeTierUsage(provider);
|
|
112
|
+
const parts: string[] = [];
|
|
113
|
+
|
|
114
|
+
if (usage.limit.requestsPerHour) {
|
|
115
|
+
parts.push(`${usage.requestsThisHour}/${usage.limit.requestsPerHour}/h`);
|
|
116
|
+
}
|
|
117
|
+
if (usage.limit.requestsPerDay) {
|
|
118
|
+
parts.push(`${usage.requestsToday}/${usage.limit.requestsPerDay}/d`);
|
|
119
|
+
}
|
|
120
|
+
if (usage.limit.requestsPerMonth) {
|
|
121
|
+
parts.push(
|
|
122
|
+
`${usage.requestsThisMonth ?? 0}/${usage.limit.requestsPerMonth}/mo`,
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (parts.length === 0) {
|
|
127
|
+
return `${provider}: ${usage.limit.description}`;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return `${provider}: ${parts.join(" | ")}`;
|
|
131
|
+
}
|