gemcatch 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.
package/gemini.js ADDED
@@ -0,0 +1,338 @@
1
+ 'use strict';
2
+
3
+ const { isDone, isSuccess } = require('./status');
4
+
5
+ // Free of charge on the Gemini free tier; override per-call with --model.
6
+ const DEFAULT_MODEL = process.env.GEMCATCH_MODEL || 'gemini-3.1-flash-lite';
7
+
8
+ // Overridable for tests and for routing via a proxy/gateway.
9
+ const REST_BASE =
10
+ process.env.GEMCATCH_BASE_URL || 'https://generativelanguage.googleapis.com/v1beta/interactions';
11
+
12
+ function envNum(name, dflt) {
13
+ const raw = process.env[name];
14
+ if (raw === undefined || raw === '') return dflt;
15
+ const n = Number(raw);
16
+ return Number.isFinite(n) ? n : dflt;
17
+ }
18
+
19
+ // The free tier allows ~15 requests/minute. Capping *concurrency* does not cap
20
+ // a rate, so every outbound call goes through gate() below instead. 0 disables
21
+ // it -- paid keys, gateways, and the test suite.
22
+ const RPM = envNum('GEMCATCH_RPM', 15);
23
+
24
+ const MAX_RETRIES = envNum('GEMCATCH_MAX_RETRIES', 4);
25
+ const RETRY_BASE_MS = envNum('GEMCATCH_RETRY_BASE_MS', 500);
26
+ const RETRY_CEIL_MS = 30000;
27
+
28
+ const KEY_HELP =
29
+ 'Get a free key at https://aistudio.google.com/apikey (no billing required), then:\n' +
30
+ ' $env:GEMINI_API_KEY="..." (PowerShell)\n' +
31
+ ' export GEMINI_API_KEY=... (bash)';
32
+
33
+ function apiKey() {
34
+ const k = process.env.GEMINI_API_KEY || process.env.GOOGLE_API_KEY;
35
+ if (!k) {
36
+ const e = new Error(`GEMINI_API_KEY is not set.\n${KEY_HELP}`);
37
+ e.code = 'NO_KEY';
38
+ throw e;
39
+ }
40
+ return k;
41
+ }
42
+
43
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
44
+
45
+ // --- rate limiting --------------------------------------------------------
46
+
47
+ let _gateTail = Promise.resolve();
48
+ let _lastStart = 0;
49
+
50
+ // Serialises *permission to start*, not the requests themselves: callers still
51
+ // overlap once through. A fresh process starts with _lastStart = 0, so a
52
+ // one-shot command is never delayed -- only fan-out (`sync`) and loops
53
+ // (`watch`, `daemon`) ever wait here.
54
+ function gate() {
55
+ if (RPM <= 0) return Promise.resolve();
56
+ const minGap = 60000 / RPM;
57
+ const turn = _gateTail.then(async () => {
58
+ const wait = _lastStart + minGap - Date.now();
59
+ if (wait > 0) await sleep(wait);
60
+ _lastStart = Date.now();
61
+ });
62
+ _gateTail = turn.catch(() => {});
63
+ return turn;
64
+ }
65
+
66
+ // --- errors ---------------------------------------------------------------
67
+
68
+ // Google sends Retry-After on some 429/503s. Seconds or an HTTP date.
69
+ function retryAfterMs(headers) {
70
+ if (!headers || typeof headers.get !== 'function') return null;
71
+ const raw = headers.get('retry-after');
72
+ if (!raw) return null;
73
+ const secs = Number(raw);
74
+ if (Number.isFinite(secs)) return Math.max(0, secs * 1000);
75
+ const at = Date.parse(raw);
76
+ return Number.isFinite(at) ? Math.max(0, at - Date.now()) : null;
77
+ }
78
+
79
+ // Google returns either {error:{...}} or [{error:{...}}]; normalise both, and
80
+ // attach the fix alongside the diagnosis. Both transports funnel through here,
81
+ // so the advice can't drift between the SDK and REST paths.
82
+ function apiError(status, body, headers) {
83
+ const node = Array.isArray(body) ? body[0] : body;
84
+ const detail = (node && node.error) || {};
85
+ let msg = detail.message || `HTTP ${status}`;
86
+ if (/API key/i.test(msg)) msg += `\n ${KEY_HELP}`;
87
+ if (detail.status === 'RESOURCE_EXHAUSTED' || status === 429) {
88
+ msg +=
89
+ '\n Free-tier rate limit — wait a minute and retry.' +
90
+ '\n Limits: https://ai.google.dev/gemini-api/docs/rate-limits';
91
+ }
92
+ const e = new Error(msg);
93
+ e.code = 'API_ERROR';
94
+ e.httpStatus = status;
95
+ const ra = retryAfterMs(headers);
96
+ if (ra !== null) e.retryAfterMs = ra;
97
+ return e;
98
+ }
99
+
100
+ // The SDK's own message is a stub ("400 API error occurred: {...}") but it keeps
101
+ // Google's real payload on .body as a raw JSON string. Dig the message out and
102
+ // run it through apiError so SDK failures read like REST ones.
103
+ function friendly(err) {
104
+ if (typeof (err && err.body) === 'string') {
105
+ try {
106
+ const node = JSON.parse(err.body);
107
+ const detail = ((Array.isArray(node) ? node[0] : node) || {}).error;
108
+ if (detail && detail.message) return apiError(err.status || detail.code, { error: detail });
109
+ } catch (_) {
110
+ /* fall through to the original error */
111
+ }
112
+ }
113
+ return err;
114
+ }
115
+
116
+ // --- retry ----------------------------------------------------------------
117
+
118
+ // Transient by nature: request timeout, rate limit, and the 5xx family. A 4xx
119
+ // is a bug in the request (bad key, bad model, unknown id) and will fail
120
+ // identically forever, so it is surfaced on the first try.
121
+ function retryableStatus(s) {
122
+ return s === 408 || s === 429 || (s >= 500 && s <= 599);
123
+ }
124
+
125
+ function shouldRetry(err) {
126
+ if (!err) return false;
127
+ if (err.code === 'NO_KEY') return false;
128
+ if (err.code === 'NETWORK') return true;
129
+ if (typeof err.httpStatus === 'number') return retryableStatus(err.httpStatus);
130
+ return false;
131
+ }
132
+
133
+ // Full jitter: a batch that trips the limit together must not retry in
134
+ // lockstep and trip it again. Retry-After wins when the server sent one.
135
+ function backoffMs(attempt, err) {
136
+ if (err && err.retryAfterMs != null) return err.retryAfterMs;
137
+ const ceiling = Math.min(RETRY_BASE_MS * 2 ** attempt, RETRY_CEIL_MS);
138
+ return Math.round(Math.random() * ceiling);
139
+ }
140
+
141
+ // Every API call in this file goes through here: paced on the way in, retried
142
+ // with backoff on the way out.
143
+ async function call(fn) {
144
+ for (let attempt = 0; ; attempt += 1) {
145
+ await gate();
146
+ try {
147
+ return await fn();
148
+ } catch (raw) {
149
+ const err = friendly(raw);
150
+ if (attempt >= MAX_RETRIES || !shouldRetry(err)) throw err;
151
+ await sleep(backoffMs(attempt, err));
152
+ }
153
+ }
154
+ }
155
+
156
+ // --- response shaping -----------------------------------------------------
157
+
158
+ // output_text is added by the SDK, so REST responses need text pulled from steps.
159
+ function collectText(node, acc) {
160
+ if (!node || typeof node !== 'object') return acc;
161
+ if (Array.isArray(node)) {
162
+ for (const n of node) collectText(n, acc);
163
+ return acc;
164
+ }
165
+ if (typeof node.text === 'string' && node.text.trim()) acc.push(node.text);
166
+ for (const v of Object.values(node)) {
167
+ if (v && typeof v === 'object') collectText(v, acc);
168
+ }
169
+ return acc;
170
+ }
171
+
172
+ // A REST interaction's `steps` interleaves the echoed prompt and the model's
173
+ // internal reasoning with the actual answer, each tagged by `type`:
174
+ // [ {type:'user_input', ...}, {type:'thought', ...}, {type:'model_output', ...} ]
175
+ // Collecting text indiscriminately prepends the prompt (and any reasoning) to
176
+ // the result, so those step types are skipped. Anything else -- model_output,
177
+ // an untyped step, a future answer-bearing type -- still contributes, so a
178
+ // renamed step never silently blanks the result.
179
+ const NON_ANSWER_STEP = new Set(['user_input', 'thought']);
180
+
181
+ function textFromSteps(steps) {
182
+ if (!Array.isArray(steps)) return '';
183
+ const acc = [];
184
+ for (const step of steps) {
185
+ if (step && NON_ANSWER_STEP.has(step.type)) continue;
186
+ collectText(step, acc);
187
+ }
188
+ return acc.join('\n').trim();
189
+ }
190
+
191
+ function textOf(interaction) {
192
+ if (interaction && typeof interaction.output_text === 'string' && interaction.output_text) {
193
+ return interaction.output_text;
194
+ }
195
+ return textFromSteps(interaction && interaction.steps);
196
+ }
197
+
198
+ function shape(r) {
199
+ return {
200
+ interactionId: r.id,
201
+ status: r.status,
202
+ text: textOf(r),
203
+ usage: r.usage || null,
204
+ raw: r,
205
+ };
206
+ }
207
+
208
+ // --- transports -----------------------------------------------------------
209
+
210
+ let _api;
211
+
212
+ function sdkInteractions() {
213
+ // GEMCATCH_FORCE_REST exercises the raw-fetch fallback without uninstalling the
214
+ // SDK. Checked every call so it always wins over the memo below.
215
+ if (process.env.GEMCATCH_FORCE_REST === '1') return null;
216
+ if (_api !== undefined) return _api;
217
+ let GoogleGenAI;
218
+ try {
219
+ ({ GoogleGenAI } = require('@google/genai'));
220
+ } catch (_) {
221
+ _api = null;
222
+ return _api;
223
+ }
224
+ // apiKey() throws before the memo is written, so a missing key keeps
225
+ // reporting itself instead of being cached as "no SDK".
226
+ const client = new GoogleGenAI({ apiKey: apiKey() });
227
+ const i = client.interactions;
228
+ // Only use the SDK if background is genuinely first-class here.
229
+ _api = i && typeof i.create === 'function' && typeof i.get === 'function' ? i : null;
230
+ return _api;
231
+ }
232
+
233
+ async function restJson(url, init) {
234
+ let res;
235
+ try {
236
+ res = await fetch(url, init);
237
+ } catch (cause) {
238
+ // DNS, connection refused, socket hang-up: worth another go.
239
+ const e = new Error(`network error talking to the API: ${cause.message}`);
240
+ e.code = 'NETWORK';
241
+ throw e;
242
+ }
243
+ const text = await res.text();
244
+ let body = null;
245
+ if (text) {
246
+ try {
247
+ body = JSON.parse(text);
248
+ } catch (_) {
249
+ // A 200 that isn't JSON means something in the middle (proxy, captive
250
+ // portal) answered for the API. Transient often enough to retry.
251
+ if (res.ok) {
252
+ const e = new Error(`the API returned a non-JSON response (HTTP ${res.status})`);
253
+ e.code = 'NETWORK';
254
+ throw e;
255
+ }
256
+ }
257
+ }
258
+ if (!res.ok) throw apiError(res.status, body, res.headers);
259
+ return Array.isArray(body) ? body[0] : body;
260
+ }
261
+
262
+ // NOTE: the API key goes in x-goog-api-key. `Authorization: Bearer <key>` is
263
+ // rejected with 401 ACCESS_TOKEN_TYPE_UNSUPPORTED (it expects an OAuth2 token).
264
+ function restHeaders() {
265
+ return { 'x-goog-api-key': apiKey(), 'Content-Type': 'application/json' };
266
+ }
267
+
268
+ // --- operations -----------------------------------------------------------
269
+
270
+ async function submit(prompt, opts) {
271
+ const o = opts || {};
272
+ const body = { model: o.model || DEFAULT_MODEL, input: prompt, background: true };
273
+ if (o.systemInstruction) body.system_instruction = o.systemInstruction;
274
+ const r = await call(() => {
275
+ const api = sdkInteractions();
276
+ return api
277
+ ? api.create(body)
278
+ : restJson(REST_BASE, { method: 'POST', headers: restHeaders(), body: JSON.stringify(body) });
279
+ });
280
+ return shape(r);
281
+ }
282
+
283
+ async function poll(interactionId) {
284
+ const r = await call(() => {
285
+ const api = sdkInteractions();
286
+ return api
287
+ ? api.get(interactionId)
288
+ : restJson(`${REST_BASE}/${encodeURIComponent(interactionId)}`, {
289
+ method: 'GET',
290
+ headers: restHeaders(),
291
+ });
292
+ });
293
+ return shape(r);
294
+ }
295
+
296
+ async function cancel(interactionId) {
297
+ const r = await call(() => {
298
+ const api = sdkInteractions();
299
+ return api
300
+ ? api.cancel(interactionId)
301
+ : restJson(`${REST_BASE}/${encodeURIComponent(interactionId)}:cancel`, {
302
+ method: 'POST',
303
+ headers: restHeaders(),
304
+ });
305
+ });
306
+ return shape(r);
307
+ }
308
+
309
+ async function remove(interactionId) {
310
+ await call(() => {
311
+ const api = sdkInteractions();
312
+ if (api) return api.delete(interactionId);
313
+ return restJson(`${REST_BASE}/${encodeURIComponent(interactionId)}`, {
314
+ method: 'DELETE',
315
+ headers: restHeaders(),
316
+ });
317
+ });
318
+ return true;
319
+ }
320
+
321
+ module.exports = {
322
+ DEFAULT_MODEL,
323
+ REST_BASE,
324
+ RPM,
325
+ MAX_RETRIES,
326
+ submit,
327
+ poll,
328
+ cancel,
329
+ remove,
330
+ apiKey,
331
+ textOf,
332
+ collectText,
333
+ // Exported for the suite: the retry policy is behaviour worth pinning.
334
+ shouldRetry,
335
+ // Re-exported so callers need only one require.
336
+ isDone,
337
+ isSuccess,
338
+ };