@umituz/react-native-ai-gemini-provider 3.0.26 → 3.0.27

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@umituz/react-native-ai-gemini-provider",
3
- "version": "3.0.26",
3
+ "version": "3.0.27",
4
4
  "description": "Google Gemini AI text generation provider for React Native applications",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./dist/index.d.ts",
@@ -118,9 +118,9 @@ export class GeminiError extends Error {
118
118
  static fromError(error: unknown, info: GeminiErrorInfo): GeminiError {
119
119
  const geminiError = new GeminiError(info);
120
120
 
121
- // If original error was an Error, preserve its stack trace
122
- if (error instanceof Error && error.stack && !geminiError.stack) {
123
- geminiError.stack = error.stack;
121
+ // Preserve original error's stack trace for better debugging
122
+ if (error instanceof Error && error.stack) {
123
+ geminiError.stack = `${geminiError.stack}\nCaused by: ${error.stack}`;
124
124
  }
125
125
 
126
126
  return geminiError;
@@ -6,9 +6,7 @@ import type { GenerationConfig } from "@google/generative-ai";
6
6
  export interface GeminiConfig {
7
7
  /** API key for authentication */
8
8
  apiKey: string;
9
- /** Optional base URL for API requests */
10
- baseUrl?: string;
11
- /** Default timeout in milliseconds */
9
+ /** Default timeout in milliseconds (used by external consumers for timeout logic) */
12
10
  defaultTimeoutMs?: number;
13
11
  /** Default model to use for text generation */
14
12
  textModel?: string;
@@ -16,11 +14,8 @@ export interface GeminiConfig {
16
14
 
17
15
  /**
18
16
  * Generation configuration for AI requests
19
- * Extends the SDK's GenerationConfig with proper schema typing
20
17
  */
21
- export type GeminiGenerationConfig = Omit<GenerationConfig, "responseSchema"> & {
22
- responseSchema?: GenerationConfig["responseSchema"];
23
- };
18
+ export type GeminiGenerationConfig = GenerationConfig;
24
19
 
25
20
  /**
26
21
  * Harm categories for content safety filtering
@@ -1,6 +1,7 @@
1
1
  import { geminiClient } from "./GeminiClient";
2
2
  import { toSdkContent } from "../utils/content-mapper.util";
3
3
  import { createGeminiError } from "../utils/error-mapper.util";
4
+ import { GeminiError } from "../../domain/entities";
4
5
  import type { GeminiContent, GeminiGenerationConfig } from "../../domain/entities";
5
6
  import type { GenerativeModel } from "@google/generative-ai";
6
7
 
@@ -31,7 +32,7 @@ export abstract class BaseGeminiService {
31
32
  }
32
33
 
33
34
  protected handleError(error: unknown, abortMessage: string): never {
34
- if (error instanceof Error && error.name === "GeminiError") {
35
+ if (error instanceof GeminiError) {
35
36
  throw error;
36
37
  }
37
38
 
@@ -64,9 +64,20 @@ export function resolveAudioMimeType(extension: string): string {
64
64
  return AUDIO_MIME[extension.toLowerCase()] ?? "audio/mp4";
65
65
  }
66
66
 
67
+ const IMAGE_MIME: Record<string, string> = {
68
+ png: "image/png",
69
+ jpg: "image/jpeg",
70
+ jpeg: "image/jpeg",
71
+ webp: "image/webp",
72
+ gif: "image/gif",
73
+ heic: "image/heic",
74
+ heif: "image/heif",
75
+ bmp: "image/bmp",
76
+ };
77
+
67
78
  /** Resolve MIME type for an image file extension */
68
79
  export function resolveImageMimeType(extension: string): string {
69
- return extension.toLowerCase() === "png" ? "image/png" : "image/jpeg";
80
+ return IMAGE_MIME[extension.toLowerCase()] ?? "image/jpeg";
70
81
  }
71
82
 
72
83
  // ─── History Utilities ───────────────────────────────────────────────────────
@@ -90,9 +101,12 @@ export function buildChatHistory(
90
101
  const last = result[result.length - 1];
91
102
 
92
103
  if (last && last.role === role) {
93
- const existingText =
94
- "text" in last.parts[0] ? (last.parts[0].text ?? "") : "";
95
- last.parts[0] = { text: existingText + "\n" + m.content };
104
+ // Merge by extracting all text from existing parts and appending new content
105
+ const existingText = last.parts
106
+ .map((p) => ("text" in p ? (p.text ?? "") : ""))
107
+ .filter(Boolean)
108
+ .join("");
109
+ last.parts = [{ text: existingText + "\n" + m.content }];
96
110
  } else {
97
111
  result.push({ role, parts: [{ text: m.content }] });
98
112
  }
@@ -162,7 +176,17 @@ export function createChatSession(config: GeminiChatConfig = {}) {
162
176
  const result = await chat.sendMessage(parts as Part[]);
163
177
  if (!result.response) throw new Error("No response from Gemini SDK");
164
178
  const candidate = result.response.candidates?.[0];
165
- const text = result.response.text();
179
+
180
+ // SDK's text() throws on safety-blocked responses with no text
181
+ let text: string;
182
+ try {
183
+ text = result.response.text();
184
+ } catch {
185
+ if (String(candidate?.finishReason) === "SAFETY") {
186
+ throw new Error("Response blocked by safety filter.");
187
+ }
188
+ throw new Error("No text content in response");
189
+ }
166
190
 
167
191
  if (__DEV__) {
168
192
  console.log("[ChatSession.send] <<< GEMINI SDK RESPONSE");
@@ -175,7 +199,7 @@ export function createChatSession(config: GeminiChatConfig = {}) {
175
199
 
176
200
  return {
177
201
  text,
178
- finishReason: candidate?.finishReason ?? undefined,
202
+ finishReason: candidate?.finishReason,
179
203
  };
180
204
  },
181
205
  };
@@ -200,6 +224,10 @@ export function createChatSession(config: GeminiChatConfig = {}) {
200
224
  export async function sendChatMessage(
201
225
  opts: SendChatMessageOptions,
202
226
  ): Promise<string> {
227
+ if (!opts.message || opts.message.trim().length === 0) {
228
+ throw new Error("Message cannot be empty");
229
+ }
230
+
203
231
  if (__DEV__) {
204
232
  console.log("═══════════════════════════════════════════════════");
205
233
  console.log("[sendChatMessage] >>> START");
@@ -26,12 +26,11 @@ class GeminiClient {
26
26
  private initialized = false;
27
27
 
28
28
  initialize(config: GeminiConfig): void {
29
- if (this.initialized) return;
30
-
31
29
  if (!config.apiKey || config.apiKey.trim().length < 10) {
32
30
  throw new Error("API key is required and must be at least 10 characters");
33
31
  }
34
32
 
33
+ // Allow re-initialization with new config (e.g. API key change)
35
34
  this.client = new GoogleGenerativeAI(config.apiKey);
36
35
  this.config = { ...DEFAULT_CONFIG, ...config };
37
36
  this.initialized = true;
@@ -16,6 +16,10 @@ class StructuredTextService {
16
16
  config?: Omit<GeminiGenerationConfig, "responseMimeType" | "responseSchema">,
17
17
  signal?: AbortSignal,
18
18
  ): Promise<T> {
19
+ if (!prompt || prompt.trim().length < 3) {
20
+ throw new Error("Prompt must be at least 3 characters");
21
+ }
22
+
19
23
  if (!schema || typeof schema !== "object" || Object.keys(schema).length === 0) {
20
24
  throw new Error("Schema must be a non-empty object");
21
25
  }
@@ -47,7 +51,7 @@ class StructuredTextService {
47
51
  throw new Error("No candidates in response");
48
52
  }
49
53
 
50
- const text = extractTextFromParts(candidates[0].content.parts);
54
+ const text = extractTextFromParts(candidates[0]?.content?.parts);
51
55
 
52
56
  if (!text || text.trim().length === 0) {
53
57
  throw new Error("Empty response received from Gemini");
@@ -40,7 +40,9 @@ class TelemetryHooks {
40
40
  * Emit a telemetry event to all listeners
41
41
  */
42
42
  emit(event: TelemetryEvent): void {
43
- for (const listener of this.listeners) {
43
+ // Snapshot to prevent mutation during iteration
44
+ const snapshot = [...this.listeners];
45
+ for (const listener of snapshot) {
44
46
  // Skip listeners that have failed too many times
45
47
  if (this.failedListeners.has(listener)) {
46
48
  continue;
@@ -85,12 +87,13 @@ class TelemetryHooks {
85
87
  * Log response received
86
88
  */
87
89
  logResponse(model: string, startTime: number, feature?: string, metadata?: Record<string, unknown>): void {
90
+ const now = Date.now();
88
91
  this.emit({
89
92
  type: "response",
90
- timestamp: Date.now(),
93
+ timestamp: now,
91
94
  model,
92
95
  feature,
93
- duration: Date.now() - startTime,
96
+ duration: now - startTime,
94
97
  metadata,
95
98
  });
96
99
  }
@@ -159,7 +159,8 @@ export function transformResponse(
159
159
  /**
160
160
  * Extract text from content parts
161
161
  */
162
- export function extractTextFromParts(parts: GeminiPart[]): string {
162
+ export function extractTextFromParts(parts: GeminiPart[] | undefined): string {
163
+ if (!parts || parts.length === 0) return "";
163
164
  return parts
164
165
  .map((part) => ("text" in part ? (part.text || "") : ""))
165
166
  .join("");
@@ -27,7 +27,7 @@ const ERROR_PATTERNS: Array<{
27
27
  retryable: false,
28
28
  },
29
29
  {
30
- pattern: ["safety", "blocked", "harmful"],
30
+ pattern: ["safety", "safety filter", "harmful", "blocked by safety"],
31
31
  type: GeminiErrorType.SAFETY,
32
32
  retryable: false,
33
33
  },
@@ -76,21 +76,46 @@ function matchesPattern(message: string, patterns: string[]): boolean {
76
76
  return patterns.some((pattern) => {
77
77
  const lowerPattern = pattern.toLowerCase();
78
78
 
79
- // Use word boundary matching for better accuracy
79
+ // Use word boundary matching for accuracy
80
80
  // This prevents "invalid" from matching "valid"
81
81
  const words = lowerPattern.split(/\s+/);
82
82
  return words.every((word) => {
83
- // Check if the word appears as a whole word or with common punctuation
84
- const regex = new RegExp(`\\b${word.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`, 'i');
85
- return regex.test(lower) || lower.includes(word);
83
+ const escaped = word.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
84
+ return new RegExp(`\\b${escaped}\\b`, 'i').test(lower);
86
85
  });
87
86
  });
88
87
  }
89
88
 
89
+ /** Map HTTP status codes to error types as a primary classification signal */
90
+ const STATUS_CODE_MAP: Record<number, { type: GeminiErrorType; retryable: boolean }> = {
91
+ 400: { type: GeminiErrorType.VALIDATION, retryable: false },
92
+ 401: { type: GeminiErrorType.AUTHENTICATION, retryable: false },
93
+ 403: { type: GeminiErrorType.AUTHENTICATION, retryable: false },
94
+ 404: { type: GeminiErrorType.MODEL_NOT_FOUND, retryable: false },
95
+ 429: { type: GeminiErrorType.RATE_LIMIT, retryable: true },
96
+ 500: { type: GeminiErrorType.SERVER, retryable: true },
97
+ 502: { type: GeminiErrorType.SERVER, retryable: true },
98
+ 503: { type: GeminiErrorType.SERVER, retryable: true },
99
+ 504: { type: GeminiErrorType.SERVER, retryable: true },
100
+ };
101
+
90
102
  function mapGeminiError(error: unknown): GeminiErrorInfo {
91
103
  const message = error instanceof Error ? error.message : String(error);
92
104
  const statusCode = getStatusCode(error);
93
105
 
106
+ // Primary: classify by HTTP status code when available
107
+ if (statusCode && STATUS_CODE_MAP[statusCode]) {
108
+ const { type, retryable } = STATUS_CODE_MAP[statusCode];
109
+ return {
110
+ type,
111
+ messageKey: `error.gemini.${type.toLowerCase()}`,
112
+ retryable,
113
+ originalError: error,
114
+ statusCode,
115
+ };
116
+ }
117
+
118
+ // Secondary: classify by error message pattern
94
119
  for (const { pattern, type, retryable } of ERROR_PATTERNS) {
95
120
  const patterns = Array.isArray(pattern) ? pattern : [pattern.source];
96
121
 
@@ -24,6 +24,10 @@ export function extractTextFromResponse(response: GeminiResponse): string {
24
24
  break;
25
25
  }
26
26
 
27
+ if (!candidate.content?.parts) {
28
+ throw new Error("No content in response candidate");
29
+ }
30
+
27
31
  const textPart = candidate.content.parts.find(
28
32
  (p): p is { text: string } => "text" in p && typeof p.text === "string",
29
33
  );
@@ -21,14 +21,12 @@ export async function processStream(
21
21
  let fullText = "";
22
22
 
23
23
  for await (const chunk of stream) {
24
- try {
25
- const chunkText = chunk.text();
26
- if (chunkText) {
27
- fullText += chunkText;
28
- safeCallChunk(onChunk, chunkText, onError);
29
- }
30
- } catch (chunkError) {
31
- logError(onError, chunkError, "stream-chunk");
24
+ // chunk.text() errors are critical (e.g. safety blocks mid-stream)
25
+ // and must propagate, unlike callback errors which are safe to swallow
26
+ const chunkText = chunk.text();
27
+ if (chunkText) {
28
+ fullText += chunkText;
29
+ safeCallChunk(onChunk, chunkText, onError);
32
30
  }
33
31
  }
34
32
 
@@ -117,8 +117,9 @@ export class ConfigBuilder {
117
117
  const builder = new ConfigBuilder();
118
118
  if (config.apiKey) builder.withApiKey(config.apiKey);
119
119
  if (config.textModel) builder.withTextModel(config.textModel);
120
- if (config.timeout) builder.withTimeout(config.timeout);
120
+ // Apply strategy first (sets default timeout), then explicit timeout overrides it
121
121
  if (config.strategy) builder.withStrategy(config.strategy);
122
+ if (config.timeout != null && config.timeout > 0) builder.withTimeout(config.timeout);
122
123
  return builder;
123
124
  }
124
125
  }
@@ -66,15 +66,8 @@ class ProviderFactory {
66
66
  throw new Error("Provider not initialized. Call initialize() first.");
67
67
  }
68
68
 
69
- // If API key changes, re-initialize
70
- if (updates.apiKey && updates.apiKey !== this.currentConfig.apiKey) {
71
- this.initialize({ ...this.currentConfig, ...updates });
72
- return;
73
- }
74
-
75
- // Otherwise, update using builder
76
- this.builder = ConfigBuilder.from({ ...this.currentConfig, ...updates });
77
- this.currentConfig = this.builder.build();
69
+ // Re-initialize with merged config to ensure GeminiClient is updated
70
+ this.initialize({ ...this.currentConfig, ...updates });
78
71
  }
79
72
  }
80
73
 
@@ -1,38 +0,0 @@
1
- import type { GeminiConfig } from "../../domain/entities";
2
- import { geminiClient } from "./GeminiClient";
3
- import { structuredText } from "./StructuredText";
4
-
5
- export class GeminiProvider {
6
- readonly providerId = "gemini";
7
- readonly providerName = "Google Gemini";
8
-
9
- initialize(config: GeminiConfig): void {
10
- geminiClient.initialize(config);
11
- }
12
-
13
- isInitialized(): boolean {
14
- return geminiClient.isInitialized();
15
- }
16
-
17
- reset(): void {
18
- geminiClient.reset();
19
- }
20
-
21
- async generateStructuredText<T>(
22
- prompt: string,
23
- schema: Record<string, unknown>,
24
- model: string,
25
- ): Promise<T> {
26
- if (!prompt || prompt.trim().length < 3) {
27
- throw new Error("Prompt must be at least 3 characters");
28
- }
29
-
30
- if (!this.isInitialized()) {
31
- throw new Error("Provider not initialized. Call initialize() first.");
32
- }
33
-
34
- return structuredText.generateStructuredText<T>(model, prompt, schema);
35
- }
36
- }
37
-
38
- export const geminiProvider = new GeminiProvider();
@@ -1,16 +0,0 @@
1
- export { geminiClient } from "./GeminiClient";
2
- export { textGeneration } from "./TextGeneration";
3
- export { structuredText } from "./StructuredText";
4
- export { streaming } from "./Streaming";
5
- export { geminiProvider, GeminiProvider } from "./GeminiProvider";
6
- export {
7
- createChatSession,
8
- sendChatMessage,
9
- buildChatHistory,
10
- trimChatHistory,
11
- resolveAudioMimeType,
12
- resolveImageMimeType,
13
- type ChatSendResult,
14
- type ChatHistoryMessage,
15
- type SendChatMessageOptions,
16
- } from "./ChatSession";
@@ -1,5 +0,0 @@
1
- /**
2
- * Telemetry Module
3
- */
4
-
5
- export { telemetryHooks } from "./TelemetryHooks";
@@ -1,8 +0,0 @@
1
- /**
2
- * Async State Management - Internal Use Only
3
- */
4
-
5
- export {
6
- executeWithState,
7
- type AsyncStateSetters,
8
- } from "./execute-state.util";
@@ -1 +0,0 @@
1
- export { useGemini, type UseGeminiOptions, type UseGeminiReturn } from "./useGemini";
@@ -1,9 +0,0 @@
1
- /**
2
- * Provider Configuration & Factory - Public API
3
- */
4
-
5
- export { ConfigBuilder } from "./ConfigBuilder";
6
- export type { ProviderConfig } from "./ConfigBuilder";
7
-
8
- export { providerFactory } from "./ProviderFactory";
9
- export type { ProviderFactoryOptions } from "./ProviderFactory";