moonpi 0.4.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.
@@ -0,0 +1,352 @@
1
+ import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext, ProviderModelConfig } from "@mariozechner/pi-coding-agent";
2
+ import { SYNTHETIC_MODELS_FALLBACK, mergeWithFallback, parseSyntheticModels, readCachedModels, writeCachedModels } from "./synthetic-models.js";
3
+
4
+ const SYNTHETIC_PROVIDER = "synthetic";
5
+ const SYNTHETIC_API_KEY_ENV = "SYNTHETIC_API_KEY";
6
+ const SYNTHETIC_OPENAI_BASE_URL = "https://api.synthetic.new/openai/v1";
7
+ const SYNTHETIC_MODELS_URL = "https://api.synthetic.new/openai/v1/models";
8
+ const SYNTHETIC_QUOTAS_URL = "https://api.synthetic.new/v2/quotas";
9
+ const FETCH_TIMEOUT_MS = 15_000;
10
+
11
+ type QuotasErrorKind = "cancelled" | "timeout" | "config" | "http" | "network";
12
+
13
+ type QuotasResult =
14
+ | { success: true; data: { quotas: QuotasResponse } }
15
+ | { success: false; error: { message: string; kind: QuotasErrorKind } };
16
+
17
+ interface QuotasResponse {
18
+ subscription?: {
19
+ limit: number;
20
+ requests: number;
21
+ renewsAt: string;
22
+ };
23
+ search?: {
24
+ hourly?: {
25
+ limit: number;
26
+ requests: number;
27
+ renewsAt: string;
28
+ };
29
+ };
30
+ freeToolCalls?: {
31
+ limit: number;
32
+ requests: number;
33
+ renewsAt: string;
34
+ };
35
+ weeklyTokenLimit?: {
36
+ nextRegenAt: string;
37
+ percentRemaining: number;
38
+ maxCredits: string;
39
+ remainingCredits: string;
40
+ nextRegenCredits: string;
41
+ };
42
+ rollingFiveHourLimit?: {
43
+ nextTickAt: string;
44
+ tickPercent: number;
45
+ remaining: number;
46
+ max: number;
47
+ limited: boolean;
48
+ };
49
+ }
50
+
51
+ function isTimeoutReason(reason: unknown): boolean {
52
+ return (
53
+ (reason instanceof DOMException && reason.name === "TimeoutError") ||
54
+ (reason instanceof Error && reason.name === "TimeoutError")
55
+ );
56
+ }
57
+
58
+ // ANSI color helpers
59
+ const ANSI_BLUE = "\x1b[34m";
60
+ const ANSI_GREEN = "\x1b[32m";
61
+ const ANSI_DIM = "\x1b[2m";
62
+ const ANSI_BOLD = "\x1b[1m";
63
+ const ANSI_RESET = "\x1b[0m";
64
+
65
+ // Fixed regeneration cadences (from Synthetic docs)
66
+ const WEEKLY_REGEN_INTERVAL_MS = 3 * 60 * 60 * 1000; // 2% every 3 hours
67
+ const ROLLING_REGEN_INTERVAL_MS = 3 * 60 * 1000; // 5% every 3 minutes
68
+
69
+ /** Parse a credit string that may contain commas or formatting into a number */
70
+ function parseCredits(value: string): number {
71
+ const cleaned = value.replace(/[^0-9.eE+-]/g, "");
72
+ const n = Number(cleaned);
73
+ return Number.isFinite(n) ? n : 0;
74
+ }
75
+
76
+ /** Format a duration in milliseconds as a human-readable string like "1d 3h 30m" */
77
+ function formatDuration(ms: number): string {
78
+ if (ms <= 0) return "now";
79
+ const totalMinutes = Math.floor(ms / (1000 * 60));
80
+ const days = Math.floor(totalMinutes / (60 * 24));
81
+ const hours = Math.floor((totalMinutes % (60 * 24)) / 60);
82
+ const minutes = totalMinutes % 60;
83
+ const parts: string[] = [];
84
+ if (days > 0) parts.push(`${days}d`);
85
+ if (hours > 0) parts.push(`${hours}h`);
86
+ if (minutes > 0) parts.push(`${minutes}m`);
87
+ return parts.length > 0 ? parts.join(" ") : "<1m";
88
+ }
89
+
90
+ /** Render a colored progress bar using ANSI block characters */
91
+ function renderBar(ratio: number, width: number, color: string): string {
92
+ const filled = Math.round(ratio * width);
93
+ const empty = width - filled;
94
+ const bar = "█".repeat(Math.max(0, filled)) + "░".repeat(Math.max(0, empty));
95
+ return `${color}${bar}${ANSI_RESET}`;
96
+ }
97
+
98
+ function formatResetTime(resetAt: string): string {
99
+ const date = new Date(resetAt);
100
+ const diffMs = date.getTime() - Date.now();
101
+ if (Number.isNaN(date.getTime())) return resetAt;
102
+ if (diffMs <= 0) return "soon";
103
+ return `in ${formatDuration(diffMs)}`;
104
+ }
105
+
106
+ function formatSyntheticQuotas(quotas: QuotasResponse): string {
107
+ const lines: string[] = [];
108
+ const BAR_WIDTH = 24;
109
+
110
+ if (quotas.weeklyTokenLimit) {
111
+ const wt = quotas.weeklyTokenLimit;
112
+ const remaining = parseCredits(wt.remainingCredits);
113
+ const max = parseCredits(wt.maxCredits);
114
+ const ratio = max > 0 ? remaining / max : 0;
115
+ const regenCredits = parseCredits(wt.nextRegenCredits);
116
+ const regenTimeStr = formatResetTime(wt.nextRegenAt);
117
+
118
+ // Weekly regenerates 2% every 3 hours at a fixed cadence.
119
+ // First regen arrives at nextRegenAt, subsequent regens every 3h.
120
+ let fullRegenStr = "N/A";
121
+ if (regenCredits > 0 && remaining < max) {
122
+ const creditsNeeded = max - remaining;
123
+ const intervalsNeeded = Math.ceil(creditsNeeded / regenCredits);
124
+ const regenDate = new Date(wt.nextRegenAt);
125
+ const firstIntervalMs = Math.max(0, regenDate.getTime() - Date.now());
126
+ // First interval is time-to-next-tick, rest use fixed 3h cadence
127
+ const fullRegenMs = firstIntervalMs + WEEKLY_REGEN_INTERVAL_MS * (intervalsNeeded - 1);
128
+ fullRegenStr = formatDuration(fullRegenMs);
129
+ } else if (remaining >= max) {
130
+ fullRegenStr = "full";
131
+ }
132
+
133
+ lines.push(`${ANSI_BOLD}Weekly Tokens${ANSI_RESET}`);
134
+ lines.push(
135
+ ` ${remaining.toLocaleString()}/${max.toLocaleString()} credits ${ANSI_DIM}(${(ratio * 100).toFixed(1)}%)${ANSI_RESET}`,
136
+ );
137
+ lines.push(` ${renderBar(ratio, BAR_WIDTH, ANSI_BLUE)}`);
138
+ lines.push(
139
+ ` Regen +${regenCredits.toLocaleString()} ${regenTimeStr} ${ANSI_DIM}Full: ${fullRegenStr}${ANSI_RESET}`,
140
+ );
141
+ }
142
+
143
+ if (quotas.rollingFiveHourLimit) {
144
+ const rf = quotas.rollingFiveHourLimit;
145
+ const remainingInt = Math.round(rf.remaining);
146
+ const maxInt = Math.round(rf.max);
147
+ const ratio = rf.max > 0 ? rf.remaining / rf.max : 0;
148
+ const state = rf.limited ? `${ANSI_DIM}limited${ANSI_RESET}` : `${ANSI_GREEN}available${ANSI_RESET}`;
149
+ const tickTimeStr = formatResetTime(rf.nextTickAt);
150
+
151
+ // Rolling 5h regenerates 5% of max every 3 minutes at a fixed cadence.
152
+ // First regen arrives at nextTickAt, subsequent regens every 3 min.
153
+ const regenPerTick = Math.max(1, Math.round(rf.max * 0.05));
154
+ let fullRegenStr = "N/A";
155
+ if (remainingInt < maxInt) {
156
+ const needed = maxInt - remainingInt;
157
+ const ticksNeeded = Math.ceil(needed / regenPerTick);
158
+ const tickDate = new Date(rf.nextTickAt);
159
+ const firstTickMs = Math.max(0, tickDate.getTime() - Date.now());
160
+ // First tick is time-to-next-tick, rest use fixed 3 min cadence
161
+ const fullRegenMs = firstTickMs + ROLLING_REGEN_INTERVAL_MS * (ticksNeeded - 1);
162
+ fullRegenStr = formatDuration(fullRegenMs);
163
+ } else {
164
+ fullRegenStr = "full";
165
+ }
166
+
167
+ lines.push(`${ANSI_BOLD}Rolling 5h${ANSI_RESET}`);
168
+ lines.push(
169
+ ` ${remainingInt}/${maxInt} requests ${state} ${ANSI_DIM}(tick ${(rf.tickPercent * 100).toFixed(0)}%)${ANSI_RESET}`,
170
+ );
171
+ lines.push(` ${renderBar(ratio, BAR_WIDTH, ANSI_GREEN)}`);
172
+ lines.push(
173
+ ` Regen +${regenPerTick} ${tickTimeStr} ${ANSI_DIM}Full: ${fullRegenStr}${ANSI_RESET}`,
174
+ );
175
+ }
176
+
177
+ if (lines.length === 0) {
178
+ lines.push(JSON.stringify(quotas, null, 2));
179
+ }
180
+
181
+ return lines.join("\n");
182
+ }
183
+
184
+ async function fetchSyntheticQuotas(apiKey: string, signal?: AbortSignal): Promise<QuotasResult> {
185
+ if (apiKey.length === 0) {
186
+ return {
187
+ success: false,
188
+ error: { message: `No API key configured. Set ${SYNTHETIC_API_KEY_ENV} or run /login synthetic.`, kind: "config" },
189
+ };
190
+ }
191
+
192
+ const signals = [AbortSignal.timeout(FETCH_TIMEOUT_MS)];
193
+ if (signal) signals.push(signal);
194
+ const combinedSignal = AbortSignal.any(signals);
195
+
196
+ try {
197
+ const response = await fetch(SYNTHETIC_QUOTAS_URL, {
198
+ headers: {
199
+ Authorization: `Bearer ${apiKey}`,
200
+ "X-Title": "moonpi",
201
+ },
202
+ signal: combinedSignal,
203
+ });
204
+
205
+ if (!response.ok) {
206
+ let message = response.statusText;
207
+ const body = await response.text();
208
+ if (body.length > 0) {
209
+ try {
210
+ const parsed = JSON.parse(body) as { error?: unknown; message?: unknown };
211
+ if (typeof parsed.error === "string") message = parsed.error;
212
+ else if (typeof parsed.message === "string") message = parsed.message;
213
+ else message = body;
214
+ } catch {
215
+ message = body;
216
+ }
217
+ }
218
+ return { success: false, error: { message, kind: "http" } };
219
+ }
220
+
221
+ return { success: true, data: { quotas: (await response.json()) as QuotasResponse } };
222
+ } catch (error: unknown) {
223
+ const aborted = combinedSignal.aborted || (error instanceof DOMException && error.name === "AbortError");
224
+ if (aborted) {
225
+ if (isTimeoutReason(combinedSignal.reason)) {
226
+ return { success: false, error: { message: "Request timed out", kind: "timeout" } };
227
+ }
228
+ return { success: false, error: { message: "Request cancelled", kind: "cancelled" } };
229
+ }
230
+
231
+ const message = error instanceof Error ? error.message : "Unknown error";
232
+ return { success: false, error: { message, kind: "network" } };
233
+ }
234
+ }
235
+
236
+ async function getSyntheticApiKey(ctx: ExtensionCommandContext | ExtensionContext): Promise<string> {
237
+ const storedKey = await ctx.modelRegistry?.getApiKeyForProvider(SYNTHETIC_PROVIDER);
238
+ return storedKey ?? process.env[SYNTHETIC_API_KEY_ENV] ?? "";
239
+ }
240
+
241
+ async function fetchSyntheticModels(apiKey: string, signal?: AbortSignal): Promise<ProviderModelConfig[] | null> {
242
+ if (!apiKey) return null;
243
+
244
+ const signals = [AbortSignal.timeout(FETCH_TIMEOUT_MS)];
245
+ if (signal) signals.push(signal);
246
+ const combinedSignal = AbortSignal.any(signals);
247
+
248
+ try {
249
+ const headers: Record<string, string> = {
250
+ "X-Title": "moonpi",
251
+ };
252
+ if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`;
253
+
254
+ const response = await fetch(SYNTHETIC_MODELS_URL, {
255
+ headers,
256
+ signal: combinedSignal,
257
+ });
258
+
259
+ if (!response.ok) return null;
260
+
261
+ const payload = await response.json();
262
+ const models = Array.isArray(payload) ? payload : payload.data;
263
+ if (!Array.isArray(models)) return null;
264
+
265
+ return parseSyntheticModels(models);
266
+ } catch {
267
+ return null;
268
+ }
269
+ }
270
+
271
+ async function handleQuotasCommand(ctx: ExtensionCommandContext): Promise<void> {
272
+ const apiKey = await getSyntheticApiKey(ctx);
273
+ const result = await fetchSyntheticQuotas(apiKey, ctx.signal);
274
+ if (!result.success) {
275
+ ctx.ui.notify(`Synthetic quotas failed: ${result.error.message}`, result.error.kind === "config" ? "warning" : "error");
276
+ return;
277
+ }
278
+
279
+ ctx.ui.notify(formatSyntheticQuotas(result.data.quotas), "info");
280
+ }
281
+
282
+ const SYNTHETIC_PROVIDER_CONFIG = {
283
+ baseUrl: SYNTHETIC_OPENAI_BASE_URL,
284
+ apiKey: SYNTHETIC_API_KEY_ENV,
285
+ api: "openai-completions" as const,
286
+ headers: {
287
+ Referer: "https://github.com/myname/moonpi",
288
+ "X-Title": "moonpi",
289
+ },
290
+ };
291
+
292
+ function registerSyntheticProvider(pi: ExtensionAPI, models: ProviderModelConfig[]): void {
293
+ pi.registerProvider(SYNTHETIC_PROVIDER, {
294
+ ...SYNTHETIC_PROVIDER_CONFIG,
295
+ models,
296
+ });
297
+ }
298
+
299
+ /**
300
+ * Fetch live models, persist to cache, and re-register the provider.
301
+ * Returns the fetched models (or null on failure).
302
+ */
303
+ async function refreshLiveModels(pi: ExtensionAPI, apiKey: string, signal?: AbortSignal): Promise<ProviderModelConfig[] | null> {
304
+ const fetchedModels = await fetchSyntheticModels(apiKey, signal);
305
+ if (fetchedModels) {
306
+ writeCachedModels(fetchedModels);
307
+ registerSyntheticProvider(pi, fetchedModels);
308
+ }
309
+ return fetchedModels;
310
+ }
311
+
312
+ export async function installSynthetic(pi: ExtensionAPI): Promise<void> {
313
+ // Fast path: read cached models from disk (synchronous, no network).
314
+ // This ensures models are available immediately at init time, which is
315
+ // critical for model restoration on session resume. Without this, models
316
+ // that aren't in the fallback list (e.g. newly-added Synthetic models)
317
+ // would not be found by modelRegistry.find() at startup.
318
+ const cachedModels = readCachedModels();
319
+ const initialModels = cachedModels
320
+ ? mergeWithFallback(cachedModels, SYNTHETIC_MODELS_FALLBACK)
321
+ : SYNTHETIC_MODELS_FALLBACK;
322
+
323
+ registerSyntheticProvider(pi, initialModels);
324
+
325
+ // Background: fetch live models from the API and update the provider + cache.
326
+ // At init time only the env-var key is available; auth storage keys are
327
+ // resolved later in the session_start handler.
328
+ const apiKey = process.env[SYNTHETIC_API_KEY_ENV] ?? "";
329
+ if (apiKey) {
330
+ // Fire and forget – we already registered with cached/fallback models,
331
+ // so startup isn't blocked on the network request.
332
+ refreshLiveModels(pi, apiKey).catch(() => {
333
+ // Silently ignore – the initialModels registration is still valid.
334
+ });
335
+ }
336
+
337
+ // On session start, resolve the API key from auth storage (supports /login)
338
+ // and refresh the model list from the live API.
339
+ pi.on("session_start", async (_event, ctx) => {
340
+ const apiKey = await getSyntheticApiKey(ctx);
341
+ // Fire-and-forget: don't block session startup on the network request.
342
+ // Cached/fallback models are already registered, so the provider is usable immediately.
343
+ refreshLiveModels(pi, apiKey, ctx.signal).catch(() => {});
344
+ });
345
+
346
+ pi.registerCommand("synthetic:quotas", {
347
+ description: "Show Synthetic weekly token and rolling 5h usage quotas",
348
+ handler: async (_args, ctx) => {
349
+ await handleQuotasCommand(ctx);
350
+ },
351
+ });
352
+ }