@miruzero/zgpt 0.1.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.
@@ -0,0 +1,166 @@
1
+ 'use strict';
2
+
3
+ const https = require('node:https');
4
+ const fs = require('node:fs');
5
+ const path = require('node:path');
6
+ const paths = require('../../config/paths');
7
+
8
+ const RETRY_AFTER_CAP_MS = 30000;
9
+ const DEFAULT_RATE_LIMIT_COOLDOWN_MS = 30000;
10
+ const MAX_RATE_LIMIT_COOLDOWN_MS = 15 * 60 * 1000;
11
+
12
+ const cleanHeaders = (input = {}) => Object.fromEntries(Object.entries(input).filter(([, value]) => value !== undefined && value !== null));
13
+ const mergeSetCookies = (cookieString, setCookie) => {
14
+ const jar = new Map();
15
+ for (const pair of String(cookieString || '').split(';')) {
16
+ const index = pair.indexOf('=');
17
+ if (index > 0) jar.set(pair.slice(0, index).trim(), pair.slice(index + 1).trim());
18
+ }
19
+ for (const entry of Array.isArray(setCookie) ? setCookie : [setCookie]) {
20
+ const first = String(entry || '').split(';')[0];
21
+ const index = first.indexOf('=');
22
+ if (index > 0) jar.set(first.slice(0, index).trim(), first.slice(index + 1).trim());
23
+ }
24
+ return [...jar].map(([name, value]) => `${name}=${value}`).join('; ');
25
+ };
26
+
27
+ const rateLimitFileOf = (token, options = {}) => {
28
+ if (options.rateLimitFile === false) return null;
29
+ if (typeof options.rateLimitFile === 'string' && options.rateLimitFile.trim()) return path.resolve(options.rateLimitFile);
30
+ if (options.rateLimitFile === undefined && process.env.NODE_TEST_CONTEXT) return null;
31
+ return paths.rateLimitFile(options.env || process.env, token);
32
+ };
33
+ const readRateLimitState = (file) => {
34
+ if (!file || !fs.existsSync(file)) return null;
35
+ try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return null; }
36
+ };
37
+ const writeRateLimitState = (file, state) => {
38
+ if (!file) return;
39
+ fs.mkdirSync(path.dirname(file), { recursive: true });
40
+ const temp = `${file}.${process.pid}.tmp`;
41
+ fs.writeFileSync(temp, `${JSON.stringify(state, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
42
+ fs.renameSync(temp, file);
43
+ };
44
+ const localRateLimitResponse = (token, options, parseBody) => {
45
+ const file = rateLimitFileOf(token, options);
46
+ const state = readRateLimitState(file);
47
+ const retryAt = Date.parse(state?.retry_not_before || '');
48
+ if (!Number.isFinite(retryAt) || retryAt <= Date.now()) return null;
49
+ const retryAfterMs = retryAt - Date.now();
50
+ return {
51
+ status: 429,
52
+ ...(parseBody ? { json: { detail: 'Too many requests' } } : { text: '{"detail":"Too many requests"}' }),
53
+ contentType: 'application/json',
54
+ headers: { 'retry-after': String(Math.max(1, Math.ceil(retryAfterMs / 1000))) },
55
+ localRateLimit: true
56
+ };
57
+ };
58
+ const recordRateLimitState = (response, token, options = {}) => {
59
+ const file = rateLimitFileOf(token, options);
60
+ if (!file || !response) return;
61
+ if (response.status === 429) {
62
+ const previous = readRateLimitState(file);
63
+ const consecutive = previous?.status === 429 ? Number(previous.consecutive_429 || 0) + 1 : 1;
64
+ const exponentialMs = Math.min(DEFAULT_RATE_LIMIT_COOLDOWN_MS * (2 ** Math.min(consecutive - 1, 8)), MAX_RATE_LIMIT_COOLDOWN_MS);
65
+ const serverMs = exports.parseRetryAfterMs(response.headers || {}) || 0;
66
+ const cooldownMs = Math.max(exponentialMs, serverMs);
67
+ writeRateLimitState(file, {
68
+ status: 429,
69
+ consecutive_429: consecutive,
70
+ observed_at: new Date().toISOString(),
71
+ retry_after_ms: cooldownMs,
72
+ retry_not_before: new Date(Date.now() + cooldownMs).toISOString()
73
+ });
74
+ return;
75
+ }
76
+ if (response.status >= 200 && response.status < 400) {
77
+ const state = readRateLimitState(file);
78
+ const retryAt = Date.parse(state?.retry_not_before || '');
79
+ if (Number.isFinite(retryAt) && retryAt <= Date.now()) {
80
+ try { fs.rmSync(file, { force: true }); } catch {}
81
+ }
82
+ }
83
+ };
84
+
85
+ exports.persistResponseHeaders = (sessionHeaders = {}, responseHeaders = {}, options = {}) => {
86
+ const setCookie = responseHeaders['set-cookie'];
87
+ if (!setCookie) return { changed: false, persisted: false };
88
+ const currentCookie = sessionHeaders.Cookie || sessionHeaders.cookie || '';
89
+ const cookie = mergeSetCookies(currentCookie, setCookie);
90
+ const changed = cookie !== currentCookie;
91
+ if (!changed) return { changed: false, persisted: false };
92
+ sessionHeaders.Cookie = cookie;
93
+ delete sessionHeaders.cookie;
94
+ const sessionFile = options.sessionFile || paths.sessionFile(options.env || process.env);
95
+ if (!fs.existsSync(sessionFile)) return { changed: true, persisted: false };
96
+ const data = JSON.parse(fs.readFileSync(sessionFile, 'utf8'));
97
+ if (data.headers && typeof data.headers === 'object') {
98
+ data.headers.Cookie = cookie;
99
+ delete data.headers.cookie;
100
+ } else data.cookie = cookie;
101
+ data.updatedAt = new Date().toISOString();
102
+ fs.writeFileSync(sessionFile, `${JSON.stringify(data, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
103
+ return { changed: true, persisted: true };
104
+ };
105
+
106
+ exports.buildHeaders = (token, sessionHeaders = {}, target, targetRoute) => {
107
+ const targetPath = target.split('?')[0];
108
+ return cleanHeaders({ ...sessionHeaders, ...(token ? { authorization: `Bearer ${token}` } : {}), 'x-openai-target-path': targetPath, 'x-openai-target-route': targetRoute || targetPath });
109
+ };
110
+ exports.parseRetryAfterMs = (headers = {}) => {
111
+ const raw = headers['retry-after'];
112
+ if (!raw) return null;
113
+ const seconds = Number(raw);
114
+ if (Number.isFinite(seconds)) return Math.min(Math.max(seconds, 0) * 1000, RETRY_AFTER_CAP_MS);
115
+ const at = Date.parse(raw);
116
+ if (Number.isFinite(at)) return Math.min(Math.max(at - Date.now(), 0), RETRY_AFTER_CAP_MS);
117
+ return null;
118
+ };
119
+ const safeParse = (text) => { try { return JSON.parse(text); } catch { return null; } };
120
+ const rawRequest = (target, token, sessionHeaders, options, parseBody) => new Promise((resolve, reject) => {
121
+ const payload = options.body === undefined ? null : Buffer.from(JSON.stringify(options.body));
122
+ let headers = exports.buildHeaders(token, sessionHeaders, target, options.route);
123
+ if (options.headers) headers = cleanHeaders({ ...headers, ...options.headers });
124
+ if (payload) { headers['content-type'] = 'application/json'; headers['content-length'] = String(payload.length); }
125
+ const req = https.request(`https://chatgpt.com${target}`, { method: options.method || 'GET', headers }, (res) => {
126
+ const chunks = [];
127
+ let ended = false;
128
+ const aborted = () => {
129
+ const error = new Error(`ChatGPT upstream stream aborted: ${target}`);
130
+ error.code = 'UPSTREAM_ABORTED';
131
+ reject(error);
132
+ };
133
+ res.on('data', (chunk) => { options.onChunk?.(chunk); chunks.push(chunk); });
134
+ res.on('end', () => {
135
+ ended = true;
136
+ const text = Buffer.concat(chunks).toString('utf8');
137
+ const response = { status: res.statusCode, ...(parseBody ? { json: safeParse(text) } : { text }), contentType: res.headers['content-type'] || null, headers: { ...res.headers } };
138
+ exports.persistResponseHeaders(sessionHeaders, response.headers, options);
139
+ resolve(response);
140
+ });
141
+ res.on('aborted', aborted);
142
+ res.on('error', reject);
143
+ res.on('close', () => { if (!ended) aborted(); });
144
+ });
145
+ req.on('error', reject);
146
+ if (Number.isFinite(options.idleTimeoutMs) && options.idleTimeoutMs > 0 && typeof req.setTimeout === 'function') {
147
+ req.setTimeout(options.idleTimeoutMs, () => {
148
+ const error = new Error(`ChatGPT upstream stream idle timeout: ${target}`);
149
+ error.code = 'UPSTREAM_IDLE_TIMEOUT';
150
+ req.destroy(error);
151
+ });
152
+ }
153
+ if (payload) req.write(payload);
154
+ req.end();
155
+ });
156
+ const guardedRequest = async (target, token, sessionHeaders, options, parseBody) => {
157
+ const limited = localRateLimitResponse(token, options, parseBody);
158
+ if (limited) return limited;
159
+ const response = await rawRequest(target, token, sessionHeaders, options, parseBody);
160
+ recordRateLimitState(response, token, options);
161
+ return response;
162
+ };
163
+ exports.requestJson = (target, token, sessionHeaders = {}, options = {}) =>
164
+ guardedRequest(target, token, sessionHeaders, options, true);
165
+ exports.requestText = (target, token, sessionHeaders = {}, options = {}) =>
166
+ guardedRequest(target, token, sessionHeaders, options, false);