hasina-gemini-cli 1.0.0 → 1.0.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/README.md CHANGED
@@ -174,10 +174,10 @@ npm start
174
174
  | `/exit` | Exit the app cleanly |
175
175
  | `/clear` | Clear in-memory history for the current session |
176
176
  | `/history` | Show recent conversation messages |
177
- | `/models` | Open a numbered Gemini model chooser |
177
+ | `/models` | Open a numbered Gemini model chooser with backend version info |
178
178
  | `/save` | Persist the current session to local session storage |
179
179
  | `/new` | Start a fresh conversation session |
180
- | `/model` | Show the currently active model |
180
+ | `/model` | Show the currently active model with backend version details |
181
181
  | `/use-model <model_name>` | Switch the active Gemini model at runtime |
182
182
  | `/system` | Show the active system prompt |
183
183
  | `/set-system <text>` | Override the current system prompt for this session |
@@ -196,11 +196,13 @@ Info > Type a message to chat, use /models to choose a model, or /help to list c
196
196
  You > /models
197
197
  Command > Choose a Gemini Model
198
198
  01. gemini-2.5-flash [active]
199
- Gemini 2.5 Flash | input 1M | output 65.5K
199
+ Gemini 2.5 Flash
200
+ version=2.5-flash | input=1M | output=65.5K
200
201
  02. gemini-2.5-pro
201
- Gemini 2.5 Pro | input 1M | output 65.5K
202
+ Gemini 2.5 Pro
203
+ version=2.5-pro | input=1M | output=65.5K
202
204
  Model > 2
203
- Success > Active model changed to "gemini-2.5-pro".
205
+ Success > Active model changed to "gemini-2.5-pro" (version 2.5-pro).
204
206
 
205
207
  You > Explain event loops in Node.js.
206
208
  Gemini > The Node.js event loop coordinates timers, I/O callbacks, microtasks,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hasina-gemini-cli",
3
- "version": "1.0.0",
3
+ "version": "1.0.2",
4
4
  "description": "Production-ready terminal AI chat application powered by the official Gemini API SDK.",
5
5
  "main": "src/index.js",
6
6
  "bin": {
package/src/app.js CHANGED
@@ -13,6 +13,32 @@ function normalizeAssistantText(text) {
13
13
  return text.trim();
14
14
  }
15
15
 
16
+ function normalizeIntentText(text) {
17
+ return String(text)
18
+ .normalize('NFD')
19
+ .replace(/[\u0300-\u036f]/g, '')
20
+ .toLowerCase()
21
+ .trim();
22
+ }
23
+
24
+ function isModelIdentityQuestion(text) {
25
+ const normalized = normalizeIntentText(text);
26
+ const patterns = [
27
+ /identifiant.*modele/,
28
+ /id.*modele/,
29
+ /modele.*exact/,
30
+ /quel.*modele.*(utilise|actif|es)/,
31
+ /donne.*modele/,
32
+ /model.*identifier/,
33
+ /exact.*model.*id/,
34
+ /what.*model.*are.*you/,
35
+ /which.*model.*are.*you/,
36
+ /model.*id/,
37
+ ];
38
+
39
+ return patterns.some((pattern) => pattern.test(normalized));
40
+ }
41
+
16
42
  class App {
17
43
  constructor({ config, provider, printer }) {
18
44
  this.config = config;
@@ -49,6 +75,10 @@ class App {
49
75
  sessionId: await this.sessionService.generateSessionId(),
50
76
  sessionCreatedAt: new Date().toISOString(),
51
77
  model: this.config.defaultModel,
78
+ activeModelInfo: {
79
+ id: this.config.defaultModel,
80
+ version: null,
81
+ },
52
82
  systemPrompt: this.config.systemPrompt,
53
83
  historyService: this.historyService,
54
84
  };
@@ -177,12 +207,15 @@ class App {
177
207
  }
178
208
 
179
209
  if (selectedModel.id === this.state.model) {
180
- this.printer.printInfo(`"${selectedModel.id}" is already the active model.`);
210
+ const versionSuffix = selectedModel.version ? ` (version ${selectedModel.version})` : '';
211
+ this.printer.printInfo(`"${selectedModel.id}" is already the active model${versionSuffix}.`);
181
212
  return false;
182
213
  }
183
214
 
184
215
  this.state.model = selectedModel.id;
185
- this.printer.printSuccess(`Active model changed to "${selectedModel.id}".`);
216
+ this.state.activeModelInfo = selectedModel;
217
+ const versionSuffix = selectedModel.version ? ` (version ${selectedModel.version})` : '';
218
+ this.printer.printSuccess(`Active model changed to "${selectedModel.id}"${versionSuffix}.`);
186
219
  return false;
187
220
  }
188
221
 
@@ -206,6 +239,12 @@ class App {
206
239
  }
207
240
 
208
241
  async handleChatTurn(userInput) {
242
+ const handledLocally = await this.tryHandleLocalModelIdentityQuestion(userInput);
243
+
244
+ if (handledLocally) {
245
+ return;
246
+ }
247
+
209
248
  const loading = this.printer.createLoadingIndicator();
210
249
  let streamStarted = false;
211
250
 
@@ -257,6 +296,24 @@ class App {
257
296
  }
258
297
  }
259
298
 
299
+ async tryHandleLocalModelIdentityQuestion(userInput) {
300
+ if (!isModelIdentityQuestion(userInput)) {
301
+ return false;
302
+ }
303
+
304
+ const modelInfo = this.state.activeModelInfo || { id: this.state.model, version: null };
305
+ let message = `Identifiant du modele (local): ${modelInfo.id || this.state.model}.`;
306
+
307
+ if (modelInfo?.version) {
308
+ message = `Identifiant du modele (local): ${modelInfo.id} (version ${modelInfo.version}).`;
309
+ }
310
+
311
+ this.printer.printAssistant(message);
312
+ this.state.historyService.addMessage('user', userInput);
313
+ this.state.historyService.addMessage('assistant', message);
314
+ return true;
315
+ }
316
+
260
317
  async shutdown() {
261
318
  if (this.readline) {
262
319
  try {
@@ -10,6 +10,8 @@ const NON_CHAT_MODEL_KEYWORDS = [
10
10
  'robotics',
11
11
  'computer-use',
12
12
  ];
13
+ const TRANSIENT_STATUS_CODES = new Set([429, 500, 502, 503, 504]);
14
+ const RETRY_DELAYS_MS = [1200, 2500];
13
15
 
14
16
  function buildGeminiChatParams({ model, systemPrompt, history }) {
15
17
  const params = {
@@ -53,7 +55,72 @@ function extractResponseText(response) {
53
55
  .join('');
54
56
  }
55
57
 
56
- function normalizeListedModel(model) {
58
+ function sleep(durationMs) {
59
+ return new Promise((resolve) => {
60
+ setTimeout(resolve, durationMs);
61
+ });
62
+ }
63
+
64
+ function getErrorStatus(error) {
65
+ if (typeof error?.status === 'number') {
66
+ return error.status;
67
+ }
68
+
69
+ if (typeof error?.cause?.status === 'number') {
70
+ return error.cause.status;
71
+ }
72
+
73
+ return undefined;
74
+ }
75
+
76
+ function getErrorMessage(error) {
77
+ if (typeof error?.message === 'string' && error.message.trim()) {
78
+ return error.message.trim();
79
+ }
80
+
81
+ if (typeof error?.cause?.message === 'string' && error.cause.message.trim()) {
82
+ return error.cause.message.trim();
83
+ }
84
+
85
+ return '';
86
+ }
87
+
88
+ function isPreviewModel(model) {
89
+ return typeof model === 'string' && /preview|exp|experimental/i.test(model);
90
+ }
91
+
92
+ function isTransientGeminiError(error) {
93
+ const status = getErrorStatus(error);
94
+ const lowerMessage = getErrorMessage(error).toLowerCase();
95
+
96
+ return (
97
+ TRANSIENT_STATUS_CODES.has(status) ||
98
+ lowerMessage.includes('high demand') ||
99
+ lowerMessage.includes('service unavailable') ||
100
+ lowerMessage.includes('temporarily unavailable') ||
101
+ lowerMessage.includes('unavailable') ||
102
+ lowerMessage.includes('rate limit') ||
103
+ lowerMessage.includes('fetch failed') ||
104
+ lowerMessage.includes('timeout') ||
105
+ lowerMessage.includes('timed out') ||
106
+ lowerMessage.includes('econnreset') ||
107
+ lowerMessage.includes('enotfound')
108
+ );
109
+ }
110
+
111
+ function buildTemporaryUnavailableMessage(model) {
112
+ const baseMessage = model
113
+ ? `Gemini is temporarily unavailable for "${model}".`
114
+ : 'Gemini is temporarily unavailable.';
115
+
116
+ if (isPreviewModel(model)) {
117
+ return `${baseMessage} Preview models can be under heavy demand. Retry in a few moments or switch with /use-model gemini-2.5-flash.`;
118
+ }
119
+
120
+ return `${baseMessage} Try again in a few moments.`;
121
+ }
122
+
123
+ function normalizeModelInfo(model) {
57
124
  const id = String(model?.name || '').replace(/^models\//, '').trim();
58
125
 
59
126
  if (!id) {
@@ -62,7 +129,9 @@ function normalizeListedModel(model) {
62
129
 
63
130
  return {
64
131
  id,
132
+ apiName: String(model.name || `models/${id}`),
65
133
  displayName: model.displayName || id,
134
+ version: typeof model.version === 'string' && model.version.trim() ? model.version.trim() : null,
66
135
  description: model.description || '',
67
136
  inputTokenLimit: Number.isFinite(model.inputTokenLimit) ? model.inputTokenLimit : null,
68
137
  outputTokenLimit: Number.isFinite(model.outputTokenLimit) ? model.outputTokenLimit : null,
@@ -100,9 +169,10 @@ function sortModels(models, currentModel) {
100
169
  });
101
170
  }
102
171
 
103
- function createFriendlyGeminiError(error, fallbackMessage) {
104
- const status = typeof error?.status === 'number' ? error.status : undefined;
105
- const message = typeof error?.message === 'string' ? error.message.trim() : '';
172
+ function createFriendlyGeminiError(error, fallbackMessage, options = {}) {
173
+ const model = options.model;
174
+ const status = getErrorStatus(error);
175
+ const message = getErrorMessage(error);
106
176
  const lowerMessage = message.toLowerCase();
107
177
 
108
178
  if (status === 400) {
@@ -152,7 +222,7 @@ function createFriendlyGeminiError(error, fallbackMessage) {
152
222
  }
153
223
 
154
224
  if (status >= 500) {
155
- return new Error('Gemini is temporarily unavailable. Try again in a few moments.', {
225
+ return new Error(buildTemporaryUnavailableMessage(model), {
156
226
  cause: error,
157
227
  });
158
228
  }
@@ -178,6 +248,26 @@ function createFriendlyGeminiError(error, fallbackMessage) {
178
248
  function createGeminiProvider({ apiKey }) {
179
249
  const client = new GoogleGenAI({ apiKey });
180
250
 
251
+ async function retryOperation(operation, { model, fallbackMessage }) {
252
+ let lastError = null;
253
+
254
+ for (let attempt = 0; attempt <= RETRY_DELAYS_MS.length; attempt += 1) {
255
+ try {
256
+ return await operation(attempt);
257
+ } catch (error) {
258
+ lastError = error;
259
+
260
+ if (!isTransientGeminiError(error) || attempt >= RETRY_DELAYS_MS.length) {
261
+ throw createFriendlyGeminiError(error, fallbackMessage, { model });
262
+ }
263
+
264
+ await sleep(RETRY_DELAYS_MS[attempt]);
265
+ }
266
+ }
267
+
268
+ throw createFriendlyGeminiError(lastError, fallbackMessage, { model });
269
+ }
270
+
181
271
  return {
182
272
  name: 'gemini',
183
273
 
@@ -189,7 +279,23 @@ function createGeminiProvider({ apiKey }) {
189
279
  } catch (error) {
190
280
  throw createFriendlyGeminiError(
191
281
  error,
192
- `Unable to validate model "${normalizedModel}".`
282
+ `Unable to validate model "${normalizedModel}".`,
283
+ { model: normalizedModel }
284
+ );
285
+ }
286
+ },
287
+
288
+ async getModelInfo(model) {
289
+ const normalizedModel = normalizeModelName(model);
290
+
291
+ try {
292
+ const response = await client.models.get({ model: normalizedModel });
293
+ return normalizeModelInfo(response);
294
+ } catch (error) {
295
+ throw createFriendlyGeminiError(
296
+ error,
297
+ `Unable to load details for model "${normalizedModel}".`,
298
+ { model: normalizedModel }
193
299
  );
194
300
  }
195
301
  },
@@ -204,7 +310,7 @@ function createGeminiProvider({ apiKey }) {
204
310
  const models = [];
205
311
 
206
312
  for await (const model of pager) {
207
- const normalized = normalizeListedModel(model);
313
+ const normalized = normalizeModelInfo(model);
208
314
 
209
315
  if (!isChatCapableModel(normalized)) {
210
316
  continue;
@@ -220,70 +326,90 @@ function createGeminiProvider({ apiKey }) {
220
326
  },
221
327
 
222
328
  async generateReply({ model, systemPrompt, history, message }) {
223
- try {
224
- const chat = client.chats.create(
225
- buildGeminiChatParams({
329
+ return retryOperation(
330
+ async () => {
331
+ const chat = client.chats.create(
332
+ buildGeminiChatParams({
333
+ model,
334
+ systemPrompt,
335
+ history,
336
+ })
337
+ );
338
+
339
+ const response = await chat.sendMessage({ message });
340
+
341
+ return {
226
342
  model,
227
- systemPrompt,
228
- history,
229
- })
230
- );
231
-
232
- const response = await chat.sendMessage({ message });
233
-
234
- return {
343
+ streamed: false,
344
+ text: extractResponseText(response),
345
+ };
346
+ },
347
+ {
235
348
  model,
236
- streamed: false,
237
- text: extractResponseText(response),
238
- };
239
- } catch (error) {
240
- throw createFriendlyGeminiError(error, 'Gemini request failed.');
241
- }
349
+ fallbackMessage: 'Gemini request failed.',
350
+ }
351
+ );
242
352
  },
243
353
 
244
354
  async streamReply({ model, systemPrompt, history, message, onTextChunk }) {
245
- try {
246
- const chat = client.chats.create(
247
- buildGeminiChatParams({
248
- model,
249
- systemPrompt,
250
- history,
251
- })
252
- );
253
-
254
- const stream = await chat.sendMessageStream({ message });
255
- let fullText = '';
256
-
257
- for await (const chunk of stream) {
258
- const chunkText = extractResponseText(chunk);
259
-
260
- if (!chunkText) {
261
- continue;
262
- }
263
-
264
- const delta = chunkText.startsWith(fullText)
265
- ? chunkText.slice(fullText.length)
266
- : chunkText;
267
-
268
- if (!delta) {
269
- continue;
355
+ return retryOperation(
356
+ async () => {
357
+ const chat = client.chats.create(
358
+ buildGeminiChatParams({
359
+ model,
360
+ systemPrompt,
361
+ history,
362
+ })
363
+ );
364
+
365
+ const stream = await chat.sendMessageStream({ message });
366
+ let fullText = '';
367
+ let emittedAnyChunk = false;
368
+
369
+ try {
370
+ for await (const chunk of stream) {
371
+ const chunkText = extractResponseText(chunk);
372
+
373
+ if (!chunkText) {
374
+ continue;
375
+ }
376
+
377
+ const delta = chunkText.startsWith(fullText)
378
+ ? chunkText.slice(fullText.length)
379
+ : chunkText;
380
+
381
+ if (!delta) {
382
+ continue;
383
+ }
384
+
385
+ fullText += delta;
386
+ emittedAnyChunk = true;
387
+
388
+ if (typeof onTextChunk === 'function') {
389
+ onTextChunk(delta);
390
+ }
391
+ }
392
+ } catch (error) {
393
+ if (emittedAnyChunk) {
394
+ throw createFriendlyGeminiError(error, 'Gemini streaming request failed.', {
395
+ model,
396
+ });
397
+ }
398
+
399
+ throw error;
270
400
  }
271
401
 
272
- fullText += delta;
273
-
274
- if (typeof onTextChunk === 'function') {
275
- onTextChunk(delta);
276
- }
277
- }
278
-
279
- return {
402
+ return {
403
+ model,
404
+ streamed: true,
405
+ text: fullText,
406
+ };
407
+ },
408
+ {
280
409
  model,
281
- streamed: true,
282
- text: fullText,
283
- };
284
- } catch (error) {
285
- throw createFriendlyGeminiError(error, 'Gemini streaming request failed.');
286
- }
410
+ fallbackMessage: 'Gemini streaming request failed.',
411
+ }
412
+ );
287
413
  },
288
414
  };
289
415
  }
@@ -68,6 +68,10 @@ function formatTokenCount(value) {
68
68
  return String(value);
69
69
  }
70
70
 
71
+ function formatModelVersion(model) {
72
+ return model?.version || 'unknown';
73
+ }
74
+
71
75
  class CommandService {
72
76
  constructor({ sessionService, provider }) {
73
77
  this.sessionService = sessionService;
@@ -129,7 +133,7 @@ class CommandService {
129
133
  return this.handleNew(state);
130
134
 
131
135
  case 'model':
132
- return messageResult('info', `Active model: ${state.model}`);
136
+ return this.handleModel(state);
133
137
 
134
138
  case 'use-model':
135
139
  return this.handleUseModel(state, command.rawArgs);
@@ -203,16 +207,49 @@ class CommandService {
203
207
  const header = `${String(index + 1).padStart(2, '0')}. ${model.id}${
204
208
  tags.length ? ` [${tags.join(', ')}]` : ''
205
209
  }`;
206
- const details = `${compactText(model.displayName, 48)} | input ${formatTokenCount(
207
- model.inputTokenLimit
208
- )} | output ${formatTokenCount(model.outputTokenLimit)}`;
209
-
210
- return `${header}\n ${details}`;
210
+ const details = [
211
+ compactText(model.displayName, 64),
212
+ `version=${formatModelVersion(model)} | input=${formatTokenCount(
213
+ model.inputTokenLimit
214
+ )} | output=${formatTokenCount(model.outputTokenLimit)}`,
215
+ ].map((line) => ` ${line}`).join('\n');
216
+
217
+ return `${header}\n${details}`;
211
218
  });
212
219
 
213
220
  return modelPickerResult('Choose a Gemini Model', lines, models);
214
221
  }
215
222
 
223
+ async handleModel(state) {
224
+ if (typeof this.provider.getModelInfo !== 'function') {
225
+ return messageResult('info', `Active model: ${state.model}`);
226
+ }
227
+
228
+ const model = await this.provider.getModelInfo(state.model);
229
+ state.activeModelInfo = model;
230
+ const tags = [];
231
+
232
+ if (model.isPreview) {
233
+ tags.push('preview');
234
+ }
235
+
236
+ if (model.isLatest) {
237
+ tags.push('latest');
238
+ }
239
+
240
+ const lines = [
241
+ `id=${model.id}${tags.length ? ` [${tags.join(', ')}]` : ''}`,
242
+ `display=${model.displayName}`,
243
+ `version=${formatModelVersion(model)}`,
244
+ `api=${model.apiName}`,
245
+ `input=${formatTokenCount(model.inputTokenLimit)} | output=${formatTokenCount(
246
+ model.outputTokenLimit
247
+ )}`,
248
+ ];
249
+
250
+ return blockResult('Active Model', lines);
251
+ }
252
+
216
253
  async handleSave(state) {
217
254
  const savedSession = await this.sessionService.saveSession({
218
255
  id: state.sessionId,
@@ -244,10 +281,16 @@ class CommandService {
244
281
  assertCommandArgument(rawArgs, 'Usage: /use-model <model_name>')
245
282
  );
246
283
 
247
- await this.provider.validateModel(modelName);
284
+ const model = typeof this.provider.getModelInfo === 'function'
285
+ ? await this.provider.getModelInfo(modelName)
286
+ : await this.provider.validateModel(modelName);
248
287
  state.model = modelName;
288
+ state.activeModelInfo = model?.id ? model : { id: modelName, version: null };
249
289
 
250
- return messageResult('success', `Active model changed to "${modelName}".`);
290
+ return messageResult(
291
+ 'success',
292
+ `Active model changed to "${modelName}" (version ${formatModelVersion(model)}).`
293
+ );
251
294
  }
252
295
 
253
296
  handleSetSystem(state, rawArgs) {
@@ -284,6 +327,7 @@ class CommandService {
284
327
  state.sessionId = session.id;
285
328
  state.sessionCreatedAt = session.createdAt;
286
329
  state.model = session.model;
330
+ state.activeModelInfo = { id: session.model, version: null };
287
331
  state.systemPrompt = session.systemPrompt;
288
332
 
289
333
  return messageResult(