@tokentop/ttop 0.2.2
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/LICENSE +21 -0
- package/README.md +273 -0
- package/bin/ttop.js +49 -0
- package/package.json +68 -0
- package/src/agents/aggregator.ts +176 -0
- package/src/agents/costing.ts +74 -0
- package/src/agents/index.ts +12 -0
- package/src/agents/types.ts +71 -0
- package/src/cli.ts +167 -0
- package/src/config/schema.test.ts +166 -0
- package/src/config/schema.ts +165 -0
- package/src/demo/simulator.ts +745 -0
- package/src/plugins/agents/index.ts +2 -0
- package/src/plugins/auth-sources.ts +186 -0
- package/src/plugins/lifecycle.ts +177 -0
- package/src/plugins/loader.test.ts +195 -0
- package/src/plugins/loader.ts +282 -0
- package/src/plugins/notification-bus.ts +153 -0
- package/src/plugins/notifications/index.ts +2 -0
- package/src/plugins/notifications/terminal-bell.ts +66 -0
- package/src/plugins/notifications/visual-flash.ts +57 -0
- package/src/plugins/npm-installer.ts +164 -0
- package/src/plugins/plugin-context-factory.ts +67 -0
- package/src/plugins/plugin-host.ts +205 -0
- package/src/plugins/providers/anthropic.ts +325 -0
- package/src/plugins/providers/antigravity.ts +309 -0
- package/src/plugins/providers/codex.ts +250 -0
- package/src/plugins/providers/gemini.ts +391 -0
- package/src/plugins/providers/github-copilot.ts +194 -0
- package/src/plugins/providers/index.ts +12 -0
- package/src/plugins/providers/minimax.ts +223 -0
- package/src/plugins/providers/openai-api.ts +250 -0
- package/src/plugins/providers/opencode-zen.ts +125 -0
- package/src/plugins/providers/perplexity.ts +177 -0
- package/src/plugins/providers/zai.ts +225 -0
- package/src/plugins/registry.ts +251 -0
- package/src/plugins/sandbox-guard.test.ts +191 -0
- package/src/plugins/sandbox-guard.ts +155 -0
- package/src/plugins/sandbox.ts +138 -0
- package/src/plugins/themes/catppuccin-latte.ts +45 -0
- package/src/plugins/themes/catppuccin-mocha.ts +45 -0
- package/src/plugins/themes/claude-code.ts +45 -0
- package/src/plugins/themes/dracula.ts +45 -0
- package/src/plugins/themes/github-light.ts +45 -0
- package/src/plugins/themes/gruvbox-dark.ts +45 -0
- package/src/plugins/themes/gruvbox-light.ts +45 -0
- package/src/plugins/themes/index.ts +15 -0
- package/src/plugins/themes/kanagawa.ts +45 -0
- package/src/plugins/themes/nord.ts +45 -0
- package/src/plugins/themes/one-dark.ts +45 -0
- package/src/plugins/themes/opencode.ts +47 -0
- package/src/plugins/themes/rose-pine-dawn.ts +45 -0
- package/src/plugins/themes/rose-pine.ts +45 -0
- package/src/plugins/themes/solarized-light.ts +45 -0
- package/src/plugins/themes/tokyo-night.ts +49 -0
- package/src/plugins/types/agent.ts +108 -0
- package/src/plugins/types/base.ts +267 -0
- package/src/plugins/types/index.ts +19 -0
- package/src/plugins/types/notification.ts +51 -0
- package/src/plugins/types/provider.ts +233 -0
- package/src/plugins/types/theme.ts +70 -0
- package/src/plugins/update-checker.ts +177 -0
- package/src/pricing/estimator.test.ts +181 -0
- package/src/pricing/estimator.ts +80 -0
- package/src/pricing/fallback.test.ts +71 -0
- package/src/pricing/fallback.ts +62 -0
- package/src/pricing/index.ts +66 -0
- package/src/pricing/models-dev.ts +130 -0
- package/src/storage/database.ts +613 -0
- package/src/storage/db.ts +64 -0
- package/src/storage/index.ts +66 -0
- package/src/storage/migrations/index.ts +189 -0
- package/src/storage/paths.ts +24 -0
- package/src/storage/repos/agentSessions.ts +210 -0
- package/src/storage/repos/providerSnapshots.ts +116 -0
- package/src/storage/repos/usageEvents.ts +334 -0
- package/src/storage/types.ts +288 -0
- package/src/tui/App.tsx +381 -0
- package/src/tui/components/CommandPalette.tsx +166 -0
- package/src/tui/components/DebugConsole.tsx +161 -0
- package/src/tui/components/DebugPanel.tsx +660 -0
- package/src/tui/components/FooterHints.tsx +22 -0
- package/src/tui/components/GhostProviderCard.tsx +45 -0
- package/src/tui/components/Header.tsx +125 -0
- package/src/tui/components/HelpOverlay.tsx +45 -0
- package/src/tui/components/InlineGauge.tsx +34 -0
- package/src/tui/components/InlineSparkline.tsx +78 -0
- package/src/tui/components/KpiStrip.tsx +246 -0
- package/src/tui/components/LimitGauge.tsx +186 -0
- package/src/tui/components/ModalBackdrop.tsx +42 -0
- package/src/tui/components/ProviderAggregateStrip.tsx +200 -0
- package/src/tui/components/ProviderCard.tsx +309 -0
- package/src/tui/components/ProviderDetailPanel.tsx +237 -0
- package/src/tui/components/ProviderLimitsPanel.tsx +308 -0
- package/src/tui/components/ProvidersList.tsx +380 -0
- package/src/tui/components/SessionDetailsDrawer.tsx +317 -0
- package/src/tui/components/SessionsTable.tsx +401 -0
- package/src/tui/components/SettingsModal.tsx +587 -0
- package/src/tui/components/SidebarBreakdown.tsx +87 -0
- package/src/tui/components/Skeleton.tsx +102 -0
- package/src/tui/components/SmartSidebar.tsx +644 -0
- package/src/tui/components/Sparkline.tsx +180 -0
- package/src/tui/components/Spinner.tsx +36 -0
- package/src/tui/components/StatusBar.tsx +96 -0
- package/src/tui/components/ThemePicker.tsx +234 -0
- package/src/tui/components/Toast.tsx +61 -0
- package/src/tui/components/UsageGauge.tsx +80 -0
- package/src/tui/components/index.ts +5 -0
- package/src/tui/contexts/AgentSessionContext.tsx +307 -0
- package/src/tui/contexts/ConfigContext.tsx +141 -0
- package/src/tui/contexts/DashboardRuntimeContext.tsx +102 -0
- package/src/tui/contexts/DemoModeContext.tsx +60 -0
- package/src/tui/contexts/DrawerContext.tsx +42 -0
- package/src/tui/contexts/InputContext.tsx +30 -0
- package/src/tui/contexts/LogContext.tsx +150 -0
- package/src/tui/contexts/PluginContext.tsx +424 -0
- package/src/tui/contexts/RealTimeActivityContext.tsx +119 -0
- package/src/tui/contexts/StorageContext.tsx +233 -0
- package/src/tui/contexts/ThemeContext.tsx +95 -0
- package/src/tui/contexts/TimeWindowContext.tsx +158 -0
- package/src/tui/contexts/ToastContext.tsx +40 -0
- package/src/tui/contexts/index.ts +5 -0
- package/src/tui/createApp.tsx +47 -0
- package/src/tui/debug/captureFrame.ts +262 -0
- package/src/tui/debug/snapshot.tsx +644 -0
- package/src/tui/driver/assertions.ts +279 -0
- package/src/tui/driver/cli.ts +717 -0
- package/src/tui/driver/coverage.ts +204 -0
- package/src/tui/driver/demo-snapshot.ts +66 -0
- package/src/tui/driver/diff.ts +166 -0
- package/src/tui/driver/driver.ts +270 -0
- package/src/tui/driver/index.ts +53 -0
- package/src/tui/driver/recorder.ts +303 -0
- package/src/tui/driver/test-workflow.ts +115 -0
- package/src/tui/driver/test.ts +47 -0
- package/src/tui/hooks/index.ts +20 -0
- package/src/tui/hooks/useAnimatedValue.ts +113 -0
- package/src/tui/hooks/useDashboardKeyboard.ts +341 -0
- package/src/tui/hooks/useDashboardState.ts +116 -0
- package/src/tui/hooks/useEmaActivity.ts +223 -0
- package/src/tui/hooks/useEntranceAnimation.ts +62 -0
- package/src/tui/hooks/useExitAnimation.ts +137 -0
- package/src/tui/hooks/usePulse.ts +138 -0
- package/src/tui/hooks/useSafeRenderer.ts +24 -0
- package/src/tui/hooks/useValueFlash.ts +111 -0
- package/src/tui/index.tsx +50 -0
- package/src/tui/utils/providerColor.ts +32 -0
- package/src/tui/views/Dashboard.tsx +340 -0
- package/src/tui/views/HistoricalTrendsView.tsx +1431 -0
- package/src/tui/views/ProjectsView.tsx +1277 -0
- package/src/tui/views/RealTimeDashboard.tsx +393 -0
- package/src/tui/views/SettingsView.tsx +465 -0
- package/src/tui/views/index.ts +2 -0
- package/src/types/global.d.ts +5 -0
- package/src/types/opentui-test-utils.d.ts +18 -0
- package/src/utils/clipboard.ts +118 -0
- package/src/utils/currency.test.ts +106 -0
- package/src/utils/currency.ts +79 -0
- package/src/version.ts +1 -0
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ProviderPlugin,
|
|
3
|
+
ProviderFetchContext,
|
|
4
|
+
ProviderUsageData,
|
|
5
|
+
Credentials,
|
|
6
|
+
CredentialResult,
|
|
7
|
+
OAuthCredentials,
|
|
8
|
+
PluginContext,
|
|
9
|
+
ProviderAuth,
|
|
10
|
+
} from '../types/provider.ts';
|
|
11
|
+
|
|
12
|
+
interface CodexCliCredentials {
|
|
13
|
+
access_token?: string;
|
|
14
|
+
refresh_token?: string;
|
|
15
|
+
expires_at?: number;
|
|
16
|
+
account_id?: string;
|
|
17
|
+
id_token?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function buildOAuthCredentials(
|
|
21
|
+
accessToken: string,
|
|
22
|
+
refreshToken?: string,
|
|
23
|
+
expiresAt?: number,
|
|
24
|
+
accountId?: string,
|
|
25
|
+
): OAuthCredentials {
|
|
26
|
+
const oauth: OAuthCredentials = { accessToken };
|
|
27
|
+
if (refreshToken !== undefined) oauth.refreshToken = refreshToken;
|
|
28
|
+
if (expiresAt !== undefined) oauth.expiresAt = expiresAt;
|
|
29
|
+
if (accountId !== undefined) oauth.accountId = accountId;
|
|
30
|
+
return oauth;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
interface CodexUsageResponse {
|
|
34
|
+
plan_type: string;
|
|
35
|
+
rate_limit?: {
|
|
36
|
+
allowed: boolean;
|
|
37
|
+
limit_reached: boolean;
|
|
38
|
+
primary_window?: {
|
|
39
|
+
used_percent: number;
|
|
40
|
+
limit_window_seconds: number;
|
|
41
|
+
reset_after_seconds: number;
|
|
42
|
+
reset_at: number;
|
|
43
|
+
} | null;
|
|
44
|
+
secondary_window?: {
|
|
45
|
+
used_percent: number;
|
|
46
|
+
limit_window_seconds: number;
|
|
47
|
+
reset_after_seconds: number;
|
|
48
|
+
reset_at: number;
|
|
49
|
+
} | null;
|
|
50
|
+
} | null;
|
|
51
|
+
credits?: {
|
|
52
|
+
has_credits: boolean;
|
|
53
|
+
unlimited: boolean;
|
|
54
|
+
balance?: string | null;
|
|
55
|
+
} | null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export const codexPlugin: ProviderPlugin = {
|
|
59
|
+
apiVersion: 2,
|
|
60
|
+
id: 'codex',
|
|
61
|
+
type: 'provider',
|
|
62
|
+
name: 'Codex',
|
|
63
|
+
version: '1.0.0',
|
|
64
|
+
|
|
65
|
+
meta: {
|
|
66
|
+
description: 'OpenAI Codex subscription usage tracking (OAuth)',
|
|
67
|
+
homepage: 'https://openai.com/codex',
|
|
68
|
+
brandColor: '#10a37f',
|
|
69
|
+
},
|
|
70
|
+
|
|
71
|
+
permissions: {
|
|
72
|
+
network: {
|
|
73
|
+
enabled: true,
|
|
74
|
+
allowedDomains: ['chatgpt.com'],
|
|
75
|
+
},
|
|
76
|
+
env: {
|
|
77
|
+
read: false,
|
|
78
|
+
vars: [],
|
|
79
|
+
},
|
|
80
|
+
filesystem: {
|
|
81
|
+
read: true,
|
|
82
|
+
paths: ['~/.codex'],
|
|
83
|
+
},
|
|
84
|
+
},
|
|
85
|
+
|
|
86
|
+
capabilities: {
|
|
87
|
+
usageLimits: true,
|
|
88
|
+
apiRateLimits: false,
|
|
89
|
+
tokenUsage: false,
|
|
90
|
+
actualCosts: false,
|
|
91
|
+
},
|
|
92
|
+
|
|
93
|
+
pricing: {
|
|
94
|
+
modelsDevProviderId: 'openai',
|
|
95
|
+
},
|
|
96
|
+
|
|
97
|
+
auth: {
|
|
98
|
+
async discover(ctx: PluginContext): Promise<CredentialResult> {
|
|
99
|
+
const entry = await ctx.authSources.opencode.getProviderEntry('openai');
|
|
100
|
+
if (entry) {
|
|
101
|
+
const accessToken = entry.accessToken || entry.access;
|
|
102
|
+
const refreshToken = entry.refreshToken || entry.refresh;
|
|
103
|
+
const expiresAt = entry.expiresAt || entry.expires;
|
|
104
|
+
if (accessToken) {
|
|
105
|
+
return {
|
|
106
|
+
ok: true,
|
|
107
|
+
credentials: {
|
|
108
|
+
oauth: buildOAuthCredentials(
|
|
109
|
+
accessToken,
|
|
110
|
+
refreshToken ?? undefined,
|
|
111
|
+
expiresAt ?? undefined,
|
|
112
|
+
entry.accountId,
|
|
113
|
+
),
|
|
114
|
+
source: 'opencode',
|
|
115
|
+
},
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Source 2: Codex CLI auth file
|
|
121
|
+
const codexPath = `${ctx.authSources.platform.homedir}/.codex/auth.json`;
|
|
122
|
+
const data = await ctx.authSources.files.readJson<CodexCliCredentials>(codexPath);
|
|
123
|
+
if (data?.access_token) {
|
|
124
|
+
return {
|
|
125
|
+
ok: true,
|
|
126
|
+
credentials: {
|
|
127
|
+
oauth: buildOAuthCredentials(
|
|
128
|
+
data.access_token,
|
|
129
|
+
data.refresh_token,
|
|
130
|
+
data.expires_at,
|
|
131
|
+
data.account_id,
|
|
132
|
+
),
|
|
133
|
+
source: 'external',
|
|
134
|
+
},
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return { ok: false, reason: 'missing', message: 'No Codex credentials found' };
|
|
139
|
+
},
|
|
140
|
+
|
|
141
|
+
isConfigured(credentials: Credentials): boolean {
|
|
142
|
+
return !!(credentials.oauth?.accessToken && credentials.oauth?.accountId);
|
|
143
|
+
},
|
|
144
|
+
} satisfies ProviderAuth,
|
|
145
|
+
|
|
146
|
+
async fetchUsage(ctx: ProviderFetchContext): Promise<ProviderUsageData> {
|
|
147
|
+
const { credentials, http, logger: log } = ctx;
|
|
148
|
+
|
|
149
|
+
if (!credentials.oauth?.accessToken) {
|
|
150
|
+
return {
|
|
151
|
+
fetchedAt: Date.now(),
|
|
152
|
+
error: 'OAuth token required. Sign in via OpenCode with ChatGPT Pro account.',
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (!credentials.oauth.accountId) {
|
|
157
|
+
return {
|
|
158
|
+
fetchedAt: Date.now(),
|
|
159
|
+
error: 'ChatGPT account ID required. Re-authenticate in OpenCode.',
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const headers: Record<string, string> = {
|
|
164
|
+
'Authorization': `Bearer ${credentials.oauth.accessToken}`,
|
|
165
|
+
'ChatGPT-Account-Id': credentials.oauth.accountId,
|
|
166
|
+
'Accept': 'application/json',
|
|
167
|
+
'Content-Type': 'application/json',
|
|
168
|
+
'User-Agent': 'tokentop',
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
try {
|
|
172
|
+
const response = await http.fetch('https://chatgpt.com/backend-api/wham/usage', {
|
|
173
|
+
method: 'GET',
|
|
174
|
+
headers,
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
if (!response.ok) {
|
|
178
|
+
const bodyText = await response.text().catch(() => '');
|
|
179
|
+
log.warn('Failed to fetch ChatGPT usage', { status: response.status, body: bodyText });
|
|
180
|
+
|
|
181
|
+
if (response.status === 401) {
|
|
182
|
+
return {
|
|
183
|
+
fetchedAt: Date.now(),
|
|
184
|
+
error: 'OAuth token expired. Re-authenticate in OpenCode.',
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
return {
|
|
189
|
+
fetchedAt: Date.now(),
|
|
190
|
+
error: `API error: ${response.status} ${response.statusText}`,
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const data = (await response.json()) as CodexUsageResponse;
|
|
195
|
+
|
|
196
|
+
const planTypeMap: Record<string, string> = {
|
|
197
|
+
'plus': 'ChatGPT Plus',
|
|
198
|
+
'pro': 'ChatGPT Pro',
|
|
199
|
+
'team': 'ChatGPT Team',
|
|
200
|
+
'enterprise': 'ChatGPT Enterprise',
|
|
201
|
+
};
|
|
202
|
+
const planType = planTypeMap[data.plan_type] ?? data.plan_type ?? 'ChatGPT';
|
|
203
|
+
|
|
204
|
+
const result: ProviderUsageData = {
|
|
205
|
+
planType,
|
|
206
|
+
allowed: data.rate_limit?.allowed ?? true,
|
|
207
|
+
limitReached: data.rate_limit?.limit_reached ?? false,
|
|
208
|
+
fetchedAt: Date.now(),
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
if (data.rate_limit) {
|
|
212
|
+
result.limits = {};
|
|
213
|
+
|
|
214
|
+
if (data.rate_limit.primary_window) {
|
|
215
|
+
const pw = data.rate_limit.primary_window;
|
|
216
|
+
result.limits.primary = {
|
|
217
|
+
usedPercent: pw.used_percent,
|
|
218
|
+
label: '5-hour window',
|
|
219
|
+
...(pw.reset_at ? { resetsAt: pw.reset_at * 1000 } : {}),
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
if (data.rate_limit.secondary_window) {
|
|
224
|
+
const sw = data.rate_limit.secondary_window;
|
|
225
|
+
result.limits.secondary = {
|
|
226
|
+
usedPercent: sw.used_percent,
|
|
227
|
+
label: '7-day window',
|
|
228
|
+
...(sw.reset_at ? { resetsAt: sw.reset_at * 1000 } : {}),
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
if (data.credits) {
|
|
234
|
+
result.credits = {
|
|
235
|
+
hasCredits: data.credits.has_credits,
|
|
236
|
+
unlimited: data.credits.unlimited,
|
|
237
|
+
...(data.credits.balance != null ? { balance: data.credits.balance } : {}),
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
return result;
|
|
242
|
+
} catch (err) {
|
|
243
|
+
log.error('Failed to fetch ChatGPT usage', { error: err });
|
|
244
|
+
return {
|
|
245
|
+
fetchedAt: Date.now(),
|
|
246
|
+
error: err instanceof Error ? err.message : 'Unknown error',
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
},
|
|
250
|
+
};
|
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ProviderPlugin,
|
|
3
|
+
ProviderFetchContext,
|
|
4
|
+
ProviderUsageData,
|
|
5
|
+
UsageLimit,
|
|
6
|
+
Credentials,
|
|
7
|
+
CredentialResult,
|
|
8
|
+
OAuthCredentials,
|
|
9
|
+
RefreshedCredentials,
|
|
10
|
+
PluginContext,
|
|
11
|
+
ProviderAuth,
|
|
12
|
+
} from '../types/provider.ts';
|
|
13
|
+
|
|
14
|
+
const GOOGLE_ENDPOINT = 'https://cloudcode-pa.googleapis.com';
|
|
15
|
+
|
|
16
|
+
const GEMINI_CLI_HEADERS = {
|
|
17
|
+
'User-Agent': 'google-api-nodejs-client/9.15.1',
|
|
18
|
+
'X-Goog-Api-Client': 'gl-node/22.17.0',
|
|
19
|
+
'Client-Metadata': 'ideType=IDE_UNSPECIFIED,platform=PLATFORM_UNSPECIFIED,pluginType=GEMINI',
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
// Base64 encoded to avoid triggering GitHub secret scanning.
|
|
23
|
+
// These are publicly-known OAuth client IDs from the open-source Gemini CLI.
|
|
24
|
+
const GEMINI_CLI_CLIENT_ID = process.env.GEMINI_CLI_CLIENT_ID
|
|
25
|
+
?? Buffer.from('NjgxMjU1ODA5Mzk1LW9vOGZ0Mm9wcmRybnA5ZTNhcWY2YXYzaG1kaWIxMzVqLmFwcHMuZ29vZ2xldXNlcmNvbnRlbnQuY29t', 'base64').toString();
|
|
26
|
+
const GEMINI_CLI_CLIENT_SECRET = process.env.GEMINI_CLI_CLIENT_SECRET
|
|
27
|
+
?? Buffer.from('R09DU1BYLTR1SGdNUG0tMW83U2stZ2VWNkN1NWNsWEZzeGw=', 'base64').toString();
|
|
28
|
+
const TOKEN_EXPIRY_BUFFER_MS = 5 * 60 * 1000;
|
|
29
|
+
|
|
30
|
+
interface GeminiCliCredentials {
|
|
31
|
+
access_token?: string;
|
|
32
|
+
refresh_token?: string;
|
|
33
|
+
expiry_date?: number;
|
|
34
|
+
token_type?: string;
|
|
35
|
+
scope?: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
interface TokenResponse {
|
|
39
|
+
access_token: string;
|
|
40
|
+
expires_in: number;
|
|
41
|
+
refresh_token?: string;
|
|
42
|
+
token_type: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
interface QuotaBucket {
|
|
46
|
+
modelId?: string;
|
|
47
|
+
tokenType?: string;
|
|
48
|
+
remainingFraction?: number;
|
|
49
|
+
resetTime?: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
interface RetrieveUserQuotaResponse {
|
|
53
|
+
buckets?: QuotaBucket[];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
interface LoadCodeAssistResponse {
|
|
57
|
+
cloudaicompanionProject?: string;
|
|
58
|
+
currentTier?: { id?: string };
|
|
59
|
+
allowedTiers?: Array<{ id?: string; isDefault?: boolean }>;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export const geminiPlugin: ProviderPlugin = {
|
|
63
|
+
apiVersion: 2,
|
|
64
|
+
id: 'gemini',
|
|
65
|
+
type: 'provider',
|
|
66
|
+
name: 'Gemini',
|
|
67
|
+
version: '1.0.0',
|
|
68
|
+
|
|
69
|
+
meta: {
|
|
70
|
+
description: 'Gemini CLI usage tracking',
|
|
71
|
+
homepage: 'https://cloud.google.com/gemini',
|
|
72
|
+
brandColor: '#4285f4',
|
|
73
|
+
providerAliases: ['google'],
|
|
74
|
+
},
|
|
75
|
+
|
|
76
|
+
permissions: {
|
|
77
|
+
network: {
|
|
78
|
+
enabled: true,
|
|
79
|
+
allowedDomains: ['googleapis.com', 'oauth2.googleapis.com'],
|
|
80
|
+
},
|
|
81
|
+
env: {
|
|
82
|
+
read: true,
|
|
83
|
+
vars: ['GOOGLE_CLOUD_PROJECT', 'GCP_PROJECT', 'GCLOUD_PROJECT'],
|
|
84
|
+
},
|
|
85
|
+
filesystem: {
|
|
86
|
+
read: true,
|
|
87
|
+
paths: ['~/.gemini'],
|
|
88
|
+
},
|
|
89
|
+
},
|
|
90
|
+
|
|
91
|
+
capabilities: {
|
|
92
|
+
usageLimits: true,
|
|
93
|
+
apiRateLimits: false,
|
|
94
|
+
tokenUsage: false,
|
|
95
|
+
actualCosts: false,
|
|
96
|
+
},
|
|
97
|
+
|
|
98
|
+
pricing: {
|
|
99
|
+
modelsDevProviderId: 'google',
|
|
100
|
+
},
|
|
101
|
+
|
|
102
|
+
auth: {
|
|
103
|
+
async discover(ctx: PluginContext): Promise<CredentialResult> {
|
|
104
|
+
const geminiPath = `${ctx.authSources.platform.homedir}/.gemini/oauth_creds.json`;
|
|
105
|
+
const geminiData = await ctx.authSources.files.readJson<GeminiCliCredentials>(geminiPath);
|
|
106
|
+
if (geminiData?.access_token || geminiData?.refresh_token) {
|
|
107
|
+
const oauth: OAuthCredentials = {
|
|
108
|
+
accessToken: geminiData.access_token ?? '',
|
|
109
|
+
...(geminiData.refresh_token !== undefined && { refreshToken: geminiData.refresh_token }),
|
|
110
|
+
...(geminiData.expiry_date !== undefined && { expiresAt: geminiData.expiry_date }),
|
|
111
|
+
};
|
|
112
|
+
return { ok: true, credentials: { oauth, source: 'external' } };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return { ok: false, reason: 'missing', message: 'No Gemini CLI credentials found. Run `gemini` to authenticate.' };
|
|
116
|
+
},
|
|
117
|
+
|
|
118
|
+
isConfigured(credentials: Credentials): boolean {
|
|
119
|
+
return !!(credentials.oauth?.accessToken || credentials.oauth?.refreshToken);
|
|
120
|
+
},
|
|
121
|
+
} satisfies ProviderAuth,
|
|
122
|
+
|
|
123
|
+
async refreshToken(auth: OAuthCredentials): Promise<RefreshedCredentials> {
|
|
124
|
+
if (!auth.refreshToken) {
|
|
125
|
+
throw new Error('No refresh token available');
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const response = await fetch('https://oauth2.googleapis.com/token', {
|
|
129
|
+
method: 'POST',
|
|
130
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
131
|
+
body: new URLSearchParams({
|
|
132
|
+
grant_type: 'refresh_token',
|
|
133
|
+
refresh_token: auth.refreshToken,
|
|
134
|
+
client_id: GEMINI_CLI_CLIENT_ID,
|
|
135
|
+
client_secret: GEMINI_CLI_CLIENT_SECRET,
|
|
136
|
+
}),
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
if (!response.ok) {
|
|
140
|
+
const errorText = await response.text().catch(() => '');
|
|
141
|
+
throw new Error(`Token refresh failed: ${response.status} ${errorText.slice(0, 100)}`);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const data = (await response.json()) as TokenResponse;
|
|
145
|
+
|
|
146
|
+
return {
|
|
147
|
+
accessToken: data.access_token,
|
|
148
|
+
refreshToken: data.refresh_token ?? auth.refreshToken,
|
|
149
|
+
expiresAt: Date.now() + data.expires_in * 1000,
|
|
150
|
+
};
|
|
151
|
+
},
|
|
152
|
+
|
|
153
|
+
async fetchUsage(ctx: ProviderFetchContext): Promise<ProviderUsageData> {
|
|
154
|
+
const { credentials, http, logger: log } = ctx;
|
|
155
|
+
|
|
156
|
+
if (!credentials.oauth?.accessToken && !credentials.oauth?.refreshToken) {
|
|
157
|
+
return {
|
|
158
|
+
fetchedAt: Date.now(),
|
|
159
|
+
error: 'OAuth token required. Login via Gemini CLI with Google account.',
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
let accessToken = credentials.oauth.accessToken;
|
|
164
|
+
const needsRefresh = !accessToken ||
|
|
165
|
+
(credentials.oauth.expiresAt && credentials.oauth.expiresAt <= Date.now() + TOKEN_EXPIRY_BUFFER_MS);
|
|
166
|
+
|
|
167
|
+
if (needsRefresh && credentials.oauth.refreshToken) {
|
|
168
|
+
try {
|
|
169
|
+
log.debug('Access token expired or missing, refreshing...');
|
|
170
|
+
const refreshed = await this.refreshToken!(credentials.oauth);
|
|
171
|
+
accessToken = refreshed.accessToken;
|
|
172
|
+
log.debug('Token refreshed successfully');
|
|
173
|
+
} catch (err) {
|
|
174
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
175
|
+
log.error('Token refresh failed', { error: msg });
|
|
176
|
+
return {
|
|
177
|
+
fetchedAt: Date.now(),
|
|
178
|
+
error: `Token refresh failed: ${msg}`,
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (!accessToken) {
|
|
184
|
+
return {
|
|
185
|
+
fetchedAt: Date.now(),
|
|
186
|
+
error: 'No valid access token and refresh failed.',
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
try {
|
|
191
|
+
const projectId = await ensureProjectContext(accessToken, http, log);
|
|
192
|
+
|
|
193
|
+
const response = await http.fetch(`${GOOGLE_ENDPOINT}/v1internal:retrieveUserQuota`, {
|
|
194
|
+
method: 'POST',
|
|
195
|
+
headers: {
|
|
196
|
+
'Authorization': `Bearer ${accessToken}`,
|
|
197
|
+
'Content-Type': 'application/json',
|
|
198
|
+
...GEMINI_CLI_HEADERS,
|
|
199
|
+
},
|
|
200
|
+
body: JSON.stringify({ project: projectId }),
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
if (!response.ok) {
|
|
204
|
+
const errorText = await response.text().catch(() => '');
|
|
205
|
+
if (response.status === 401) {
|
|
206
|
+
return {
|
|
207
|
+
fetchedAt: Date.now(),
|
|
208
|
+
error: 'OAuth token expired or invalid. Re-authenticate via Gemini CLI.',
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
return {
|
|
212
|
+
fetchedAt: Date.now(),
|
|
213
|
+
error: `Quota request failed: ${response.status} ${errorText.slice(0, 100)}`,
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const data = (await response.json()) as RetrieveUserQuotaResponse;
|
|
218
|
+
const buckets = data.buckets ?? [];
|
|
219
|
+
|
|
220
|
+
if (buckets.length === 0) {
|
|
221
|
+
return {
|
|
222
|
+
planType: 'Gemini Code Assist',
|
|
223
|
+
allowed: true,
|
|
224
|
+
fetchedAt: Date.now(),
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const quotaItems: UsageLimit[] = [];
|
|
229
|
+
let lowestRemaining = 1.0;
|
|
230
|
+
let resetTime: string | undefined;
|
|
231
|
+
|
|
232
|
+
for (const bucket of buckets) {
|
|
233
|
+
if (typeof bucket.remainingFraction === 'number') {
|
|
234
|
+
const usedPercent = Math.round((1 - bucket.remainingFraction) * 100);
|
|
235
|
+
const label = `${bucket.modelId ?? 'Unknown'} ${bucket.tokenType ?? ''}`.trim();
|
|
236
|
+
|
|
237
|
+
const limit: UsageLimit = {
|
|
238
|
+
usedPercent,
|
|
239
|
+
label,
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
if (bucket.resetTime) {
|
|
243
|
+
const resetDate = new Date(bucket.resetTime).getTime();
|
|
244
|
+
if (!isNaN(resetDate)) {
|
|
245
|
+
limit.resetsAt = resetDate;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
quotaItems.push(limit);
|
|
250
|
+
|
|
251
|
+
if (bucket.remainingFraction < lowestRemaining) {
|
|
252
|
+
lowestRemaining = bucket.remainingFraction;
|
|
253
|
+
resetTime = bucket.resetTime;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
quotaItems.sort((a, b) => (b.usedPercent ?? 0) - (a.usedPercent ?? 0));
|
|
259
|
+
|
|
260
|
+
const usedPercent = Math.max(0, Math.min(100, (1 - lowestRemaining) * 100));
|
|
261
|
+
const limitReached = lowestRemaining <= 0;
|
|
262
|
+
|
|
263
|
+
let resetsAt: number | undefined;
|
|
264
|
+
if (resetTime) {
|
|
265
|
+
const resetDate = new Date(resetTime).getTime();
|
|
266
|
+
if (!isNaN(resetDate)) {
|
|
267
|
+
resetsAt = resetDate;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const result: ProviderUsageData = {
|
|
272
|
+
planType: 'Gemini Code Assist',
|
|
273
|
+
allowed: !limitReached,
|
|
274
|
+
limitReached,
|
|
275
|
+
fetchedAt: Date.now(),
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
if (quotaItems.length > 0) {
|
|
279
|
+
const primary = quotaItems[0];
|
|
280
|
+
if (primary) {
|
|
281
|
+
const primaryLimit: UsageLimit = {
|
|
282
|
+
usedPercent,
|
|
283
|
+
label: 'Daily',
|
|
284
|
+
};
|
|
285
|
+
if (resetsAt !== undefined) {
|
|
286
|
+
primaryLimit.resetsAt = resetsAt;
|
|
287
|
+
}
|
|
288
|
+
result.limits = {
|
|
289
|
+
primary: primaryLimit,
|
|
290
|
+
...(quotaItems[1] ? { secondary: quotaItems[1] } : {}),
|
|
291
|
+
items: quotaItems,
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
return result;
|
|
297
|
+
} catch (err) {
|
|
298
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
299
|
+
log.error('Failed to fetch Google usage', { error: msg });
|
|
300
|
+
return {
|
|
301
|
+
fetchedAt: Date.now(),
|
|
302
|
+
error: msg,
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
},
|
|
306
|
+
};
|
|
307
|
+
|
|
308
|
+
async function ensureProjectContext(
|
|
309
|
+
accessToken: string,
|
|
310
|
+
http: ProviderFetchContext['http'],
|
|
311
|
+
log: ProviderFetchContext['logger']
|
|
312
|
+
): Promise<string> {
|
|
313
|
+
const configuredProjectId = process.env.GOOGLE_CLOUD_PROJECT ??
|
|
314
|
+
process.env.GCP_PROJECT ??
|
|
315
|
+
process.env.GCLOUD_PROJECT;
|
|
316
|
+
|
|
317
|
+
if (configuredProjectId?.trim()) {
|
|
318
|
+
return configuredProjectId.trim();
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
try {
|
|
322
|
+
const response = await http.fetch(`${GOOGLE_ENDPOINT}/v1internal:loadCodeAssist`, {
|
|
323
|
+
method: 'POST',
|
|
324
|
+
headers: {
|
|
325
|
+
'Authorization': `Bearer ${accessToken}`,
|
|
326
|
+
'Content-Type': 'application/json',
|
|
327
|
+
...GEMINI_CLI_HEADERS,
|
|
328
|
+
},
|
|
329
|
+
body: JSON.stringify({
|
|
330
|
+
metadata: {
|
|
331
|
+
ideType: 'IDE_UNSPECIFIED',
|
|
332
|
+
platform: 'PLATFORM_UNSPECIFIED',
|
|
333
|
+
pluginType: 'GEMINI',
|
|
334
|
+
},
|
|
335
|
+
}),
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
if (!response.ok) {
|
|
339
|
+
log.debug('loadCodeAssist failed, using default project discovery');
|
|
340
|
+
return await onboardToFreeProject(accessToken, http, log);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
const data = (await response.json()) as LoadCodeAssistResponse;
|
|
344
|
+
|
|
345
|
+
if (data.cloudaicompanionProject) {
|
|
346
|
+
return data.cloudaicompanionProject;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
return await onboardToFreeProject(accessToken, http, log);
|
|
350
|
+
} catch (err) {
|
|
351
|
+
log.debug('Project discovery failed', { error: err instanceof Error ? err.message : String(err) });
|
|
352
|
+
throw new Error('Failed to discover Google Cloud project. Set GOOGLE_CLOUD_PROJECT environment variable.');
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
async function onboardToFreeProject(
|
|
357
|
+
accessToken: string,
|
|
358
|
+
http: ProviderFetchContext['http'],
|
|
359
|
+
log: ProviderFetchContext['logger']
|
|
360
|
+
): Promise<string> {
|
|
361
|
+
const response = await http.fetch(`${GOOGLE_ENDPOINT}/v1internal:onboardUser`, {
|
|
362
|
+
method: 'POST',
|
|
363
|
+
headers: {
|
|
364
|
+
'Authorization': `Bearer ${accessToken}`,
|
|
365
|
+
'Content-Type': 'application/json',
|
|
366
|
+
...GEMINI_CLI_HEADERS,
|
|
367
|
+
},
|
|
368
|
+
body: JSON.stringify({
|
|
369
|
+
tierId: 'FREE',
|
|
370
|
+
metadata: {
|
|
371
|
+
ideType: 'IDE_UNSPECIFIED',
|
|
372
|
+
platform: 'PLATFORM_UNSPECIFIED',
|
|
373
|
+
pluginType: 'GEMINI',
|
|
374
|
+
},
|
|
375
|
+
}),
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
if (!response.ok) {
|
|
379
|
+
const errorText = await response.text().catch(() => '');
|
|
380
|
+
log.error('onboardUser failed', { status: response.status, error: errorText.slice(0, 100) });
|
|
381
|
+
throw new Error('Failed to onboard to free tier. Set GOOGLE_CLOUD_PROJECT environment variable.');
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
const data = (await response.json()) as { response?: { cloudaicompanionProject?: { id?: string } }; done?: boolean };
|
|
385
|
+
|
|
386
|
+
if (data.done && data.response?.cloudaicompanionProject?.id) {
|
|
387
|
+
return data.response.cloudaicompanionProject.id;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
throw new Error('Onboarding incomplete. Set GOOGLE_CLOUD_PROJECT environment variable.');
|
|
391
|
+
}
|