@0xkahi/pi-qol 0.0.1 → 0.0.3
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/README.md +93 -12
- package/assets/config.schema.json +190 -2
- package/package.json +1 -1
- package/src/config-loader.ts +8 -4
- package/src/constants.ts +20 -0
- package/src/extensions/auto-session-name/index.ts +1 -1
- package/src/extensions/custom-footer/constants.ts +4 -0
- package/src/extensions/custom-footer/footer-component.ts +179 -0
- package/src/extensions/custom-footer/index.ts +18 -0
- package/src/extensions/custom-footer/progress-bar.ts +16 -0
- package/src/extensions/custom-footer/subscription-usage-manager.ts +91 -0
- package/src/extensions/custom-footer/token-stats.ts +144 -0
- package/src/extensions/custom-footer/types.ts +40 -0
- package/src/extensions/model-select/constants.ts +8 -0
- package/src/extensions/model-select/index.ts +119 -0
- package/src/extensions/model-select/model-formatter.ts +60 -0
- package/src/extensions/model-select/model-lists.ts +63 -0
- package/src/extensions/model-select/model-select-dialog.ts +340 -0
- package/src/extensions/model-select/types.ts +31 -0
- package/src/index.ts +4 -0
- package/src/libs/subscription-usage/strategy/anthropic-oauth-usage.strategy.ts +41 -0
- package/src/libs/subscription-usage/strategy/openai-codex-usage.strategy.ts +75 -0
- package/src/libs/subscription-usage/subscription-usage-api.util.ts +78 -0
- package/src/schemas/auto-session-name.config.schema.ts +2 -2
- package/src/schemas/config.schema.ts +11 -0
- package/src/schemas/custom-footer-config.schema.ts +64 -0
- package/src/schemas/model-select.config.schema.ts +18 -0
- package/src/schemas/shared-config.schema.ts +5 -2
- package/src/types.ts +1 -0
- package/src/utils/crayon.util.ts +58 -0
- package/src/utils/model-resolver.util.ts +2 -0
- package/src/utils/path.util.ts +4 -0
- package/src/utils/raw-data-parser.util.ts +14 -0
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
import { modelsAreEqual } from '@earendil-works/pi-ai';
|
|
2
|
+
import type { KeybindingsManager, Theme } from '@earendil-works/pi-coding-agent';
|
|
3
|
+
import { type Component, type Focusable, fuzzyFilter, Input, type TUI, truncateToWidth, visibleWidth } from '@earendil-works/pi-tui';
|
|
4
|
+
import { MAX_CONFIG_WARNING_LINES, MAX_VISIBLE_MODELS } from './constants';
|
|
5
|
+
import { ModelFormatter } from './model-formatter';
|
|
6
|
+
import type { DialogOptions, ModelItem, SelectionSection } from './types';
|
|
7
|
+
|
|
8
|
+
function isPrintableInput(data: string): boolean {
|
|
9
|
+
if (data.length === 0) {
|
|
10
|
+
return false;
|
|
11
|
+
}
|
|
12
|
+
return [...data].every(char => {
|
|
13
|
+
const code = char.charCodeAt(0);
|
|
14
|
+
return code >= 32 && code !== 0x7f && (code < 0x80 || code > 0x9f);
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export class ModelSelectDialog implements Component, Focusable {
|
|
19
|
+
private readonly searchInput = new Input();
|
|
20
|
+
private activeSection: SelectionSection;
|
|
21
|
+
private selectedFavouriteIndex = 0;
|
|
22
|
+
private selectedSearchIndex = 0;
|
|
23
|
+
private filteredSearchItems: ModelItem[];
|
|
24
|
+
private _focused = false;
|
|
25
|
+
|
|
26
|
+
constructor(
|
|
27
|
+
private readonly tui: TUI,
|
|
28
|
+
private readonly theme: Theme,
|
|
29
|
+
private readonly keybindings: KeybindingsManager,
|
|
30
|
+
private readonly options: DialogOptions,
|
|
31
|
+
) {
|
|
32
|
+
this.activeSection = options.initialSearch || !options.hasFavouriteSection ? 'search' : 'favourites';
|
|
33
|
+
const currentFavouriteIndex = options.currentModel
|
|
34
|
+
? options.favouriteItems.findIndex(item => modelsAreEqual(item.model, options.currentModel))
|
|
35
|
+
: -1;
|
|
36
|
+
this.selectedFavouriteIndex = currentFavouriteIndex >= 0 ? currentFavouriteIndex : 0;
|
|
37
|
+
this.searchInput.setValue(options.initialSearch);
|
|
38
|
+
this.searchInput.onSubmit = () => this.selectCurrentSearchItem();
|
|
39
|
+
this.searchInput.onEscape = () => this.options.onDone(null);
|
|
40
|
+
this.filteredSearchItems = this.filterSearchItems(options.initialSearch);
|
|
41
|
+
this.syncFocus();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
get focused(): boolean {
|
|
45
|
+
return this._focused;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
set focused(value: boolean) {
|
|
49
|
+
this._focused = value;
|
|
50
|
+
this.syncFocus();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
invalidate(): void {
|
|
54
|
+
this.searchInput.invalidate();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
handleInput(data: string): void {
|
|
58
|
+
if (this.keybindings.matches(data, 'tui.input.tab')) {
|
|
59
|
+
this.switchSection();
|
|
60
|
+
this.tui.requestRender();
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (this.keybindings.matches(data, 'tui.select.cancel')) {
|
|
65
|
+
this.options.onDone(null);
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (this.keybindings.matches(data, 'tui.select.up')) {
|
|
70
|
+
this.moveSelection(-1);
|
|
71
|
+
this.tui.requestRender();
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (this.keybindings.matches(data, 'tui.select.down')) {
|
|
76
|
+
this.moveSelection(1);
|
|
77
|
+
this.tui.requestRender();
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (this.keybindings.matches(data, 'tui.select.pageUp')) {
|
|
82
|
+
this.moveSelection(-MAX_VISIBLE_MODELS);
|
|
83
|
+
this.tui.requestRender();
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (this.keybindings.matches(data, 'tui.select.pageDown')) {
|
|
88
|
+
this.moveSelection(MAX_VISIBLE_MODELS);
|
|
89
|
+
this.tui.requestRender();
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (this.keybindings.matches(data, 'tui.select.confirm')) {
|
|
94
|
+
this.selectCurrentItem();
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (this.activeSection !== 'search' && isPrintableInput(data)) {
|
|
99
|
+
this.activeSection = 'search';
|
|
100
|
+
this.syncFocus();
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (this.activeSection === 'search') {
|
|
104
|
+
this.searchInput.handleInput(data);
|
|
105
|
+
this.filteredSearchItems = this.filterSearchItems(this.searchInput.getValue());
|
|
106
|
+
this.selectedSearchIndex = Math.min(this.selectedSearchIndex, Math.max(0, this.filteredSearchItems.length - 1));
|
|
107
|
+
this.tui.requestRender();
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
render(width: number): string[] {
|
|
112
|
+
const safeWidth = Math.max(3, width);
|
|
113
|
+
|
|
114
|
+
if (this.options.layout === 'inline') {
|
|
115
|
+
const inner = safeWidth;
|
|
116
|
+
const lines = this.buildContentLines(inner);
|
|
117
|
+
const rule = this.theme.fg('border', '─'.repeat(inner));
|
|
118
|
+
const body = lines.map(line => this.padLine(line, inner));
|
|
119
|
+
return [rule, ...body, rule];
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const inner = safeWidth - 2;
|
|
123
|
+
const lines = this.buildContentLines(inner);
|
|
124
|
+
|
|
125
|
+
const borderColor = (str: string) => this.theme.fg('border', str);
|
|
126
|
+
const horizontal = '─'.repeat(inner);
|
|
127
|
+
const top = borderColor(`╭${horizontal}╮`);
|
|
128
|
+
const bottom = borderColor(`╰${horizontal}╯`);
|
|
129
|
+
const side = borderColor('│');
|
|
130
|
+
|
|
131
|
+
const wrapped = lines.map(line => `${side}${this.padLine(line, inner)}${side}`);
|
|
132
|
+
return [top, ...wrapped, bottom];
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
private buildContentLines(inner: number): string[] {
|
|
136
|
+
const lines: string[] = [];
|
|
137
|
+
|
|
138
|
+
lines.push(this.line(this.renderTitle(), inner));
|
|
139
|
+
lines.push(this.line(this.renderTabs(), inner));
|
|
140
|
+
|
|
141
|
+
for (const warning of this.options.configWarnings.slice(0, MAX_CONFIG_WARNING_LINES)) {
|
|
142
|
+
lines.push(this.line(this.theme.fg('warning', `⚠ ${warning}`), inner));
|
|
143
|
+
}
|
|
144
|
+
if (this.options.configWarnings.length > MAX_CONFIG_WARNING_LINES) {
|
|
145
|
+
lines.push(
|
|
146
|
+
this.line(this.theme.fg('warning', `⚠ ${this.options.configWarnings.length - MAX_CONFIG_WARNING_LINES} more config warning(s)`), inner),
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
lines.push('');
|
|
151
|
+
|
|
152
|
+
if (this.activeSection === 'favourites') {
|
|
153
|
+
lines.push(...this.renderFavourites(inner));
|
|
154
|
+
} else {
|
|
155
|
+
lines.push(...this.renderSearch(inner));
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
lines.push('');
|
|
159
|
+
lines.push(this.line(this.renderHelp(), inner));
|
|
160
|
+
|
|
161
|
+
return lines;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
private padLine(text: string, innerWidth: number): string {
|
|
165
|
+
const truncated = truncateToWidth(text.replace(/[\r\n]+/g, ' '), innerWidth, '');
|
|
166
|
+
const padding = Math.max(0, innerWidth - visibleWidth(truncated));
|
|
167
|
+
return truncated + ' '.repeat(padding);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
private switchSection(): void {
|
|
171
|
+
if (!this.options.hasFavouriteSection) {
|
|
172
|
+
this.activeSection = 'search';
|
|
173
|
+
this.syncFocus();
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
this.activeSection = this.activeSection === 'favourites' ? 'search' : 'favourites';
|
|
178
|
+
this.syncFocus();
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
private syncFocus(): void {
|
|
182
|
+
this.searchInput.focused = this._focused && this.activeSection === 'search';
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
private moveSelection(delta: number): void {
|
|
186
|
+
if (this.activeSection === 'favourites') {
|
|
187
|
+
this.selectedFavouriteIndex = this.wrapIndex(this.selectedFavouriteIndex + delta, this.options.favouriteItems.length);
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
this.selectedSearchIndex = this.wrapIndex(this.selectedSearchIndex + delta, this.filteredSearchItems.length);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
private wrapIndex(index: number, length: number): number {
|
|
195
|
+
if (length <= 0) {
|
|
196
|
+
return 0;
|
|
197
|
+
}
|
|
198
|
+
return ((index % length) + length) % length;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
private selectCurrentItem(): void {
|
|
202
|
+
if (this.activeSection === 'favourites') {
|
|
203
|
+
const item = this.options.favouriteItems[this.selectedFavouriteIndex];
|
|
204
|
+
if (item) {
|
|
205
|
+
this.options.onDone(item.model);
|
|
206
|
+
}
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
this.selectCurrentSearchItem();
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
private selectCurrentSearchItem(): void {
|
|
214
|
+
const item = this.filteredSearchItems[this.selectedSearchIndex];
|
|
215
|
+
if (item) {
|
|
216
|
+
this.options.onDone(item.model);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
private filterSearchItems(query: string): ModelItem[] {
|
|
221
|
+
const trimmed = query.trim();
|
|
222
|
+
if (!trimmed) {
|
|
223
|
+
return this.options.searchItems;
|
|
224
|
+
}
|
|
225
|
+
return fuzzyFilter(this.options.searchItems, trimmed, item => item.searchText);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
private renderTitle(): string {
|
|
229
|
+
const current = this.options.currentModel ? ModelFormatter.modelLabel(this.options.currentModel) : 'none';
|
|
230
|
+
return `${this.theme.fg('customMessageLabel', this.theme.bold('Select Model'))} ${this.theme.fg('muted', `current: ${current}`)}`;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
private renderTabs(): string {
|
|
234
|
+
const tabs: string[] = [];
|
|
235
|
+
if (this.options.hasFavouriteSection) {
|
|
236
|
+
tabs.push(this.renderTab('favourites', `Favourites ${this.options.favouriteItems.length}`));
|
|
237
|
+
}
|
|
238
|
+
tabs.push(this.renderTab('search', `Search ${this.options.searchItems.length}`));
|
|
239
|
+
return tabs.join(this.theme.fg('muted', ' '));
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
private renderTab(section: SelectionSection, label: string): string {
|
|
243
|
+
const text = `[${label}]`;
|
|
244
|
+
if (this.activeSection === section) {
|
|
245
|
+
return this.theme.fg('accent', this.theme.bold(text));
|
|
246
|
+
}
|
|
247
|
+
return this.theme.fg('muted', text);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
private renderFavourites(width: number): string[] {
|
|
251
|
+
const lines: string[] = [];
|
|
252
|
+
|
|
253
|
+
if (this.options.favouriteItems.length === 0) {
|
|
254
|
+
lines.push(this.line(this.theme.fg('muted', ' No configured favourites are available.'), width));
|
|
255
|
+
} else {
|
|
256
|
+
lines.push(...this.renderModelList(this.options.favouriteItems, this.selectedFavouriteIndex, width));
|
|
257
|
+
const selected = this.options.favouriteItems[this.selectedFavouriteIndex];
|
|
258
|
+
if (selected) {
|
|
259
|
+
lines.push('');
|
|
260
|
+
lines.push(this.line(this.theme.fg('muted', ` ${selected.description}`), width));
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
for (const warning of this.options.favouriteWarnings.slice(0, MAX_CONFIG_WARNING_LINES)) {
|
|
265
|
+
lines.push(this.line(this.theme.fg('warning', ` ⚠ ${warning}`), width));
|
|
266
|
+
}
|
|
267
|
+
if (this.options.favouriteWarnings.length > MAX_CONFIG_WARNING_LINES) {
|
|
268
|
+
lines.push(
|
|
269
|
+
this.line(
|
|
270
|
+
this.theme.fg('warning', ` ⚠ ${this.options.favouriteWarnings.length - MAX_CONFIG_WARNING_LINES} more favourite warning(s)`),
|
|
271
|
+
width,
|
|
272
|
+
),
|
|
273
|
+
);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
return lines;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
private renderSearch(width: number): string[] {
|
|
280
|
+
const lines: string[] = [];
|
|
281
|
+
const providerText =
|
|
282
|
+
this.options.providerFilter.length === 0 ? 'all authorized providers' : `providers: ${this.options.providerFilter.join(', ')}`;
|
|
283
|
+
|
|
284
|
+
lines.push(this.line(this.theme.fg('muted', `Provider filter: ${providerText}`), width));
|
|
285
|
+
lines.push(this.line(this.theme.fg('muted', 'Search query:'), width));
|
|
286
|
+
lines.push(...this.searchInput.render(width));
|
|
287
|
+
lines.push('');
|
|
288
|
+
|
|
289
|
+
if (this.filteredSearchItems.length === 0) {
|
|
290
|
+
lines.push(this.line(this.theme.fg('muted', ' No matching models'), width));
|
|
291
|
+
return lines;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
lines.push(...this.renderModelList(this.filteredSearchItems, this.selectedSearchIndex, width));
|
|
295
|
+
const selected = this.filteredSearchItems[this.selectedSearchIndex];
|
|
296
|
+
if (selected) {
|
|
297
|
+
lines.push('');
|
|
298
|
+
lines.push(this.line(this.theme.fg('muted', ` ${selected.description}`), width));
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
return lines;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
private renderModelList(items: ModelItem[], selectedIndex: number, width: number): string[] {
|
|
305
|
+
const lines: string[] = [];
|
|
306
|
+
const startIndex = Math.max(0, Math.min(selectedIndex - Math.floor(MAX_VISIBLE_MODELS / 2), items.length - MAX_VISIBLE_MODELS));
|
|
307
|
+
const endIndex = Math.min(startIndex + MAX_VISIBLE_MODELS, items.length);
|
|
308
|
+
|
|
309
|
+
for (let index = startIndex; index < endIndex; index++) {
|
|
310
|
+
const item = items[index];
|
|
311
|
+
if (!item) {
|
|
312
|
+
continue;
|
|
313
|
+
}
|
|
314
|
+
lines.push(this.renderModelItem(item, index === selectedIndex, width));
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
if (startIndex > 0 || endIndex < items.length) {
|
|
318
|
+
lines.push(this.line(this.theme.fg('dim', ` (${selectedIndex + 1}/${items.length})`), width));
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
return lines;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
private renderModelItem(item: ModelItem, isSelected: boolean, width: number): string {
|
|
325
|
+
const currentMark = modelsAreEqual(item.model, this.options.currentModel) ? this.theme.fg('success', ' ✓') : '';
|
|
326
|
+
const providerBadge = this.theme.fg('muted', `[${item.model.provider}]`);
|
|
327
|
+
const prefix = isSelected ? this.theme.fg('accent', '→ ') : ' ';
|
|
328
|
+
const modelText = isSelected ? this.theme.fg('accent', item.model.id) : item.model.id;
|
|
329
|
+
return this.line(`${prefix}${modelText} ${providerBadge}${currentMark}`, width);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
private renderHelp(): string {
|
|
333
|
+
const tabHint = this.options.hasFavouriteSection ? 'tab switch sections • ' : '';
|
|
334
|
+
return this.theme.fg('dim', `${tabHint}↑↓ navigate • enter select • esc cancel`);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
private line(text: string, width: number): string {
|
|
338
|
+
return truncateToWidth(text.replace(/[\r\n]+/g, ' '), Math.max(1, width), '');
|
|
339
|
+
}
|
|
340
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { Api, Model } from '@earendil-works/pi-ai';
|
|
2
|
+
import type { ModelSelectLayout } from '../../schemas/model-select.config.schema';
|
|
3
|
+
|
|
4
|
+
export type ModelItem = {
|
|
5
|
+
model: Model<Api>;
|
|
6
|
+
description: string;
|
|
7
|
+
searchText: string;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export type ModelLists = {
|
|
11
|
+
favouriteItems: ModelItem[];
|
|
12
|
+
favouriteWarnings: string[];
|
|
13
|
+
searchItems: ModelItem[];
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export type SelectionSection = 'favourites' | 'search';
|
|
17
|
+
|
|
18
|
+
export type DialogResult = Model<Api> | null;
|
|
19
|
+
|
|
20
|
+
export type DialogOptions = {
|
|
21
|
+
currentModel: Model<Api> | undefined;
|
|
22
|
+
favouriteItems: ModelItem[];
|
|
23
|
+
favouriteWarnings: string[];
|
|
24
|
+
hasFavouriteSection: boolean;
|
|
25
|
+
searchItems: ModelItem[];
|
|
26
|
+
providerFilter: string[];
|
|
27
|
+
configWarnings: string[];
|
|
28
|
+
initialSearch: string;
|
|
29
|
+
layout: ModelSelectLayout;
|
|
30
|
+
onDone: (result: DialogResult) => void;
|
|
31
|
+
};
|
package/src/index.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
|
|
2
2
|
import { ConfigLoader } from './config-loader';
|
|
3
3
|
import { registerAutoSessionName } from './extensions/auto-session-name';
|
|
4
|
+
import { registerCustomFooter } from './extensions/custom-footer';
|
|
5
|
+
import { registerModelSelect } from './extensions/model-select';
|
|
4
6
|
|
|
5
7
|
export default function (pi: ExtensionAPI) {
|
|
6
8
|
const config = new ConfigLoader();
|
|
@@ -11,4 +13,6 @@ export default function (pi: ExtensionAPI) {
|
|
|
11
13
|
});
|
|
12
14
|
|
|
13
15
|
registerAutoSessionName(pi, { config });
|
|
16
|
+
registerModelSelect(pi, { config });
|
|
17
|
+
registerCustomFooter(pi, { config });
|
|
14
18
|
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { RawDataParser } from '../../../utils/raw-data-parser.util';
|
|
2
|
+
import type { ProviderAuth, RateWindow, SubscriptionUsageStrategy } from '../subscription-usage-api.util';
|
|
3
|
+
|
|
4
|
+
export class AnthropicOauthUsageStrategy implements SubscriptionUsageStrategy {
|
|
5
|
+
readonly provider = 'anthropic';
|
|
6
|
+
readonly label = 'Claude';
|
|
7
|
+
|
|
8
|
+
async fetchUsage(auth: ProviderAuth) {
|
|
9
|
+
const response = await fetch(this.request(auth));
|
|
10
|
+
const data = RawDataParser.asRecord(await response.json());
|
|
11
|
+
if (!data) return;
|
|
12
|
+
|
|
13
|
+
const windows: RateWindow[] = [];
|
|
14
|
+
for (const [key, label] of [
|
|
15
|
+
['five_hour', '5h'],
|
|
16
|
+
['seven_day', 'Week'],
|
|
17
|
+
] as const) {
|
|
18
|
+
const source = RawDataParser.asRecord(data[key]);
|
|
19
|
+
const usedPercent = RawDataParser.numberValue(source?.utilization);
|
|
20
|
+
if (usedPercent === undefined) continue;
|
|
21
|
+
const resetAt = RawDataParser.stringValue(source?.resets_at);
|
|
22
|
+
const resetDate = resetAt ? new Date(resetAt) : undefined;
|
|
23
|
+
windows.push({
|
|
24
|
+
label,
|
|
25
|
+
usedPercent,
|
|
26
|
+
resetAt: resetDate && Number.isFinite(resetDate.getTime()) ? resetDate : undefined,
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
return windows;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
private request(opts: ProviderAuth) {
|
|
33
|
+
return new Request('https://api.anthropic.com/api/oauth/usage', {
|
|
34
|
+
method: 'GET',
|
|
35
|
+
headers: {
|
|
36
|
+
Authorization: `Bearer ${opts.token}`,
|
|
37
|
+
'anthropic-beta': 'oauth-2025-04-20',
|
|
38
|
+
},
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { RawDataParser } from '../../../utils/raw-data-parser.util';
|
|
2
|
+
import type { ProviderAuth, RateWindow, SubscriptionUsageStrategy } from '../subscription-usage-api.util';
|
|
3
|
+
|
|
4
|
+
const PRIMARY_WINDOW_FALLBACK_SECONDS = 10800;
|
|
5
|
+
const SECONDARY_WINDOW_FALLBACK_SECONDS = 86400;
|
|
6
|
+
|
|
7
|
+
export class OpenAiCodexUsageStrategy implements SubscriptionUsageStrategy {
|
|
8
|
+
readonly provider = 'openai-codex';
|
|
9
|
+
readonly label = 'Codex';
|
|
10
|
+
|
|
11
|
+
async fetchUsage(auth: ProviderAuth) {
|
|
12
|
+
const response = await fetch(this.request(auth));
|
|
13
|
+
if (!response.ok) return;
|
|
14
|
+
|
|
15
|
+
const data = RawDataParser.asRecord(await response.json());
|
|
16
|
+
if (!data) return;
|
|
17
|
+
|
|
18
|
+
const windows: RateWindow[] = [];
|
|
19
|
+
this.pushRateLimitWindows(windows, RawDataParser.asRecord(data.rate_limit));
|
|
20
|
+
|
|
21
|
+
const additionalRateLimits = Array.isArray(data.additional_rate_limits) ? data.additional_rate_limits : [];
|
|
22
|
+
for (const item of additionalRateLimits) {
|
|
23
|
+
const entry = RawDataParser.asRecord(item);
|
|
24
|
+
if (!entry) continue;
|
|
25
|
+
|
|
26
|
+
const prefix = RawDataParser.stringValue(entry.limit_name) ?? RawDataParser.stringValue(entry.metered_feature) ?? 'Additional';
|
|
27
|
+
this.pushRateLimitWindows(windows, RawDataParser.asRecord(entry.rate_limit), prefix);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return windows.length > 0 ? windows : undefined;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
private pushRateLimitWindows(windows: RateWindow[], rateLimit: Record<string, unknown> | undefined, prefix?: string): void {
|
|
34
|
+
this.pushRateWindow(windows, RawDataParser.asRecord(rateLimit?.primary_window), PRIMARY_WINDOW_FALLBACK_SECONDS, prefix);
|
|
35
|
+
this.pushRateWindow(windows, RawDataParser.asRecord(rateLimit?.secondary_window), SECONDARY_WINDOW_FALLBACK_SECONDS, prefix);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
private pushRateWindow(windows: RateWindow[], window: Record<string, unknown> | undefined, fallbackWindowSeconds: number, prefix?: string): void {
|
|
39
|
+
if (!window) return;
|
|
40
|
+
|
|
41
|
+
const resetDate = this.getResetDate(window);
|
|
42
|
+
windows.push({
|
|
43
|
+
label: this.getWindowLabel(RawDataParser.numberValue(window.limit_window_seconds), fallbackWindowSeconds, prefix),
|
|
44
|
+
usedPercent: RawDataParser.numberValue(window.used_percent) ?? 0,
|
|
45
|
+
resetAt: resetDate,
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
private getResetDate(window: Record<string, unknown>): Date | undefined {
|
|
50
|
+
const resetSeconds = RawDataParser.numberValue(window.reset_at);
|
|
51
|
+
if (!resetSeconds) return;
|
|
52
|
+
|
|
53
|
+
const resetDate = new Date(resetSeconds * 1000);
|
|
54
|
+
return Number.isFinite(resetDate.getTime()) ? resetDate : undefined;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
private getWindowLabel(windowSeconds: number | undefined, fallbackWindowSeconds: number, prefix?: string): string {
|
|
58
|
+
const seconds = windowSeconds && windowSeconds > 0 ? windowSeconds : fallbackWindowSeconds;
|
|
59
|
+
const hours = Math.round(seconds / 3600);
|
|
60
|
+
|
|
61
|
+
const label = hours >= 144 ? 'Week' : hours >= 24 ? 'Day' : `${hours}h`;
|
|
62
|
+
return prefix ? `${prefix} ${label}` : label;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
private request(opts: ProviderAuth) {
|
|
66
|
+
return new Request('https://chatgpt.com/backend-api/wham/usage', {
|
|
67
|
+
method: 'GET',
|
|
68
|
+
headers: {
|
|
69
|
+
Authorization: `Bearer ${opts.token}`,
|
|
70
|
+
...(opts?.accountId ? { 'ChatGPT-Account-Id': opts.accountId } : {}),
|
|
71
|
+
Accept: 'application/json',
|
|
72
|
+
},
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { PathUtil } from '../../utils/path.util';
|
|
3
|
+
import { RawDataParser } from '../../utils/raw-data-parser.util';
|
|
4
|
+
|
|
5
|
+
export type ProviderAuth = {
|
|
6
|
+
token: string;
|
|
7
|
+
accountId?: string;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export type SubscriptionProvider = 'anthropic' | 'openai-codex';
|
|
11
|
+
type SubscriptionAuthconfig = Partial<Record<SubscriptionProvider, Record<string, unknown>>>;
|
|
12
|
+
|
|
13
|
+
export type RateWindow = {
|
|
14
|
+
label: string;
|
|
15
|
+
usedPercent: number;
|
|
16
|
+
resetAt?: Date;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export type FetchUsageResponse = {
|
|
20
|
+
label: string;
|
|
21
|
+
rateWindow: RateWindow[];
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export interface SubscriptionUsageStrategy {
|
|
25
|
+
readonly provider: SubscriptionProvider;
|
|
26
|
+
readonly label: string;
|
|
27
|
+
fetchUsage(auth: ProviderAuth): Promise<RateWindow[] | undefined>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export class SubscriptionUsageApi {
|
|
31
|
+
async fetchUsage(strategy: SubscriptionUsageStrategy): Promise<FetchUsageResponse | undefined> {
|
|
32
|
+
const auth = this.getOauthProviderAuth(strategy.provider);
|
|
33
|
+
if (!auth) return;
|
|
34
|
+
const rateWindow = await strategy.fetchUsage(auth);
|
|
35
|
+
if (!rateWindow) return;
|
|
36
|
+
return {
|
|
37
|
+
label: strategy.label,
|
|
38
|
+
rateWindow,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
formatResetDescription(date: Date): string {
|
|
43
|
+
const diffMs = date.getTime() - Date.now();
|
|
44
|
+
if (diffMs <= 0) return 'now';
|
|
45
|
+
|
|
46
|
+
const minutes = Math.floor(diffMs / 60000);
|
|
47
|
+
if (minutes < 60) return `${minutes}m`;
|
|
48
|
+
|
|
49
|
+
const hours = Math.floor(minutes / 60);
|
|
50
|
+
const remainingMinutes = minutes % 60;
|
|
51
|
+
if (hours < 24) return remainingMinutes > 0 ? `${hours}h${remainingMinutes}m` : `${hours}h`;
|
|
52
|
+
|
|
53
|
+
const days = Math.floor(hours / 24);
|
|
54
|
+
const remainingHours = hours % 24;
|
|
55
|
+
return remainingHours > 0 ? `${days}d${remainingHours}h` : `${days}d`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
private getOauthProviderAuth(provider: SubscriptionProvider): ProviderAuth | undefined {
|
|
59
|
+
const auth = this.loadAuthConfig();
|
|
60
|
+
if (!auth) return;
|
|
61
|
+
|
|
62
|
+
const providerAuth = auth[provider];
|
|
63
|
+
const access = RawDataParser.stringValue(providerAuth?.access);
|
|
64
|
+
if (!access) return;
|
|
65
|
+
|
|
66
|
+
return {
|
|
67
|
+
token: access,
|
|
68
|
+
accountId: RawDataParser.stringValue(providerAuth?.accountId),
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
private loadAuthConfig(): SubscriptionAuthconfig | undefined {
|
|
73
|
+
const authJson = PathUtil.findPiAuthConfig();
|
|
74
|
+
if (!authJson.exists) return undefined;
|
|
75
|
+
|
|
76
|
+
return RawDataParser.asRecord(JSON.parse(readFileSync(authJson.path, 'utf-8'))) as SubscriptionAuthconfig | undefined;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import z from 'zod';
|
|
2
|
-
import {
|
|
2
|
+
import { ModelConfigSchema } from './shared-config.schema';
|
|
3
3
|
|
|
4
4
|
export const AutoSessionNameConfigSchema = z.object({
|
|
5
5
|
enabled: z.boolean().default(false),
|
|
6
|
-
model:
|
|
6
|
+
model: ModelConfigSchema.optional(),
|
|
7
7
|
});
|
|
8
8
|
export type AutoSessionNameConfig = z.infer<typeof AutoSessionNameConfigSchema>;
|
|
9
9
|
|
|
@@ -1,16 +1,27 @@
|
|
|
1
1
|
import z from 'zod';
|
|
2
2
|
import { AutoSessionNameConfigSchema, PartialAutoSessionNameConfigSchema } from './auto-session-name.config.schema';
|
|
3
|
+
import { CustomFooterConfigSchema, DEFAULT_CUSTOM_FOOTER_CONFIG, PartialCustomFooterConfigSchema } from './custom-footer-config.schema';
|
|
4
|
+
import { ModelSelectConfigSchema, PartialModelSelectConfigSchema } from './model-select.config.schema';
|
|
3
5
|
|
|
4
6
|
export const ConfigSchema = z.object({
|
|
5
7
|
$schema: z.string().optional(),
|
|
6
8
|
auto_session_name: AutoSessionNameConfigSchema.default({
|
|
7
9
|
enabled: false,
|
|
8
10
|
}),
|
|
11
|
+
model_select: ModelSelectConfigSchema.default({
|
|
12
|
+
enabled: false,
|
|
13
|
+
favourite: [],
|
|
14
|
+
provider_filter: [],
|
|
15
|
+
layout: 'inline',
|
|
16
|
+
}),
|
|
17
|
+
custom_footer: CustomFooterConfigSchema.default(DEFAULT_CUSTOM_FOOTER_CONFIG),
|
|
9
18
|
});
|
|
10
19
|
export type Config = z.infer<typeof ConfigSchema>;
|
|
11
20
|
|
|
12
21
|
export const PartialConfigSchema = z.object({
|
|
13
22
|
$schema: z.string().optional(),
|
|
14
23
|
auto_session_name: PartialAutoSessionNameConfigSchema.optional(),
|
|
24
|
+
model_select: PartialModelSelectConfigSchema.optional(),
|
|
25
|
+
custom_footer: PartialCustomFooterConfigSchema.optional(),
|
|
15
26
|
});
|
|
16
27
|
export type PartialConfig = z.infer<typeof PartialConfigSchema>;
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import z from 'zod';
|
|
2
|
+
import { ColorHexSchema } from './shared-config.schema';
|
|
3
|
+
|
|
4
|
+
const CustomFooterColorsSchema = z.object({
|
|
5
|
+
directory: ColorHexSchema.optional(),
|
|
6
|
+
modelName: ColorHexSchema.optional(),
|
|
7
|
+
anthropicUsage: ColorHexSchema.optional().default('#D97706'),
|
|
8
|
+
codexUsage: ColorHexSchema.optional().default('#10B981'),
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
const CustomFooterIconsSchema = z.object({
|
|
12
|
+
directory: z.string().optional().default(' '),
|
|
13
|
+
refresh: z.string().optional().default(''),
|
|
14
|
+
cache: z.string().optional().default(' '),
|
|
15
|
+
cacheRead: z.string().optional().default(' '),
|
|
16
|
+
cacheWrite: z.string().optional().default(' '),
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
const CustomFooterDisplaySchema = z.object({
|
|
20
|
+
tokens: z.boolean().optional().default(true),
|
|
21
|
+
cache: z.boolean().optional().default(true),
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
const DEFAULT_CUSTOM_FOOTER_COLORS = CustomFooterColorsSchema.parse({});
|
|
25
|
+
const DEFAULT_CUSTOM_FOOTER_ICONS = CustomFooterIconsSchema.parse({});
|
|
26
|
+
const DEFAULT_CUSTOM_FOOTER_DISPLAY = CustomFooterDisplaySchema.parse({});
|
|
27
|
+
|
|
28
|
+
export const CustomFooterConfigSchema = z.object({
|
|
29
|
+
enabled: z.boolean().default(false),
|
|
30
|
+
colors: CustomFooterColorsSchema.default(DEFAULT_CUSTOM_FOOTER_COLORS),
|
|
31
|
+
icons: CustomFooterIconsSchema.default(DEFAULT_CUSTOM_FOOTER_ICONS),
|
|
32
|
+
display: CustomFooterDisplaySchema.default(DEFAULT_CUSTOM_FOOTER_DISPLAY),
|
|
33
|
+
});
|
|
34
|
+
export type CustomFooterConfig = z.infer<typeof CustomFooterConfigSchema>;
|
|
35
|
+
|
|
36
|
+
export const DEFAULT_CUSTOM_FOOTER_CONFIG = CustomFooterConfigSchema.parse({ enabled: false });
|
|
37
|
+
|
|
38
|
+
export const PartialCustomFooterConfigSchema = z.object({
|
|
39
|
+
enabled: z.boolean().optional(),
|
|
40
|
+
colors: z
|
|
41
|
+
.object({
|
|
42
|
+
directory: ColorHexSchema.optional(),
|
|
43
|
+
modelName: ColorHexSchema.optional(),
|
|
44
|
+
anthropicUsage: ColorHexSchema.optional(),
|
|
45
|
+
codexUsage: ColorHexSchema.optional(),
|
|
46
|
+
})
|
|
47
|
+
.optional(),
|
|
48
|
+
icons: z
|
|
49
|
+
.object({
|
|
50
|
+
directory: z.string().optional(),
|
|
51
|
+
refresh: z.string().optional(),
|
|
52
|
+
cache: z.string().optional(),
|
|
53
|
+
cacheRead: z.string().optional(),
|
|
54
|
+
cacheWrite: z.string().optional(),
|
|
55
|
+
})
|
|
56
|
+
.optional(),
|
|
57
|
+
display: z
|
|
58
|
+
.object({
|
|
59
|
+
tokens: z.boolean().optional(),
|
|
60
|
+
cache: z.boolean().optional(),
|
|
61
|
+
})
|
|
62
|
+
.optional(),
|
|
63
|
+
});
|
|
64
|
+
export type PartialCustomFooterConfig = z.infer<typeof PartialCustomFooterConfigSchema>;
|