@game_ryo/lsji 1.0.0 → 1.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,323 @@
1
+ /**
2
+ * Google Gemini Provider
3
+ *
4
+ * Implements LLMProvider interface for Google's Gemini API.
5
+ * Supports function calling, streaming, and token counting.
6
+ */
7
+
8
+ import { LLMProvider } from './base.js';
9
+
10
+ const DEFAULT_BASE_URL = 'https://generativelanguage.googleapis.com/v1beta';
11
+
12
+ /**
13
+ * Gemini Provider
14
+ */
15
+ export class GeminiProvider extends LLMProvider {
16
+ constructor(config = {}) {
17
+ super(config);
18
+ this.baseUrl = config.baseUrl || DEFAULT_BASE_URL;
19
+ this.apiVersion = config.apiVersion || 'v1beta';
20
+ }
21
+
22
+ /**
23
+ * Generate completion from Gemini
24
+ */
25
+ async generate(messages, options = {}) {
26
+ const { temperature = 0.7, maxTokens = 4096, tools, toolChoice = 'auto' } = options;
27
+
28
+ // Convert messages to Gemini format
29
+ const contents = this.convertMessages(messages);
30
+ const systemInstruction = this.extractSystemInstruction(messages);
31
+
32
+ const requestBody = {
33
+ contents,
34
+ generationConfig: {
35
+ temperature,
36
+ maxOutputTokens: maxTokens,
37
+ topP: 0.95,
38
+ topK: 40,
39
+ },
40
+ };
41
+
42
+ // Add system instruction if present
43
+ if (systemInstruction) {
44
+ requestBody.systemInstruction = { parts: [{ text: systemInstruction }] };
45
+ }
46
+
47
+ // Add tools if provided
48
+ if (tools && tools.length > 0) {
49
+ requestBody.tools = [{ functionDeclarations: this.convertTools(tools) }];
50
+ requestBody.toolConfig = { functionCallingConfig: { mode: this.convertToolChoice(toolChoice) } };
51
+ }
52
+
53
+ const url = `${this.baseUrl}/models/${this.model}:generateContent?key=${this.apiKey}`;
54
+
55
+ const response = await fetch(url, {
56
+ method: 'POST',
57
+ headers: { 'Content-Type': 'application/json' },
58
+ body: JSON.stringify(requestBody),
59
+ });
60
+
61
+ if (!response.ok) {
62
+ const error = await response.json().catch(() => ({}));
63
+ throw new Error(`Gemini API error: ${response.status} - ${error.error?.message || response.statusText}`);
64
+ }
65
+
66
+ const data = await response.json();
67
+ return this.parseResponse(data);
68
+ }
69
+
70
+ /**
71
+ * Generate streaming completion
72
+ */
73
+ async *generateStream(messages, options = {}) {
74
+ const { temperature = 0.7, maxTokens = 4096, tools, toolChoice = 'auto' } = options;
75
+
76
+ const contents = this.convertMessages(messages);
77
+ const systemInstruction = this.extractSystemInstruction(messages);
78
+
79
+ const requestBody = {
80
+ contents,
81
+ generationConfig: {
82
+ temperature,
83
+ maxOutputTokens: maxTokens,
84
+ topP: 0.95,
85
+ topK: 40,
86
+ },
87
+ };
88
+
89
+ if (systemInstruction) {
90
+ requestBody.systemInstruction = { parts: [{ text: systemInstruction }] };
91
+ }
92
+
93
+ if (tools && tools.length > 0) {
94
+ requestBody.tools = [{ functionDeclarations: this.convertTools(tools) }];
95
+ requestBody.toolConfig = { functionCallingConfig: { mode: this.convertToolChoice(toolChoice) } };
96
+ }
97
+
98
+ const url = `${this.baseUrl}/models/${this.model}:streamGenerateContent?key=${this.apiKey}`;
99
+
100
+ const response = await fetch(url, {
101
+ method: 'POST',
102
+ headers: { 'Content-Type': 'application/json' },
103
+ body: JSON.stringify(requestBody),
104
+ });
105
+
106
+ if (!response.ok) {
107
+ const error = await response.json().catch(() => ({}));
108
+ throw new Error(`Gemini API error: ${response.status} - ${error.error?.message || response.statusText}`);
109
+ }
110
+
111
+ const reader = response.body.getReader();
112
+ const decoder = new TextDecoder();
113
+ let buffer = '';
114
+
115
+ while (true) {
116
+ const { done, value } = await reader.read();
117
+ if (done) break;
118
+
119
+ buffer += decoder.decode(value, { stream: true });
120
+ const lines = buffer.split('\n');
121
+ buffer = lines.pop() || '';
122
+
123
+ for (const line of lines) {
124
+ if (line.startsWith('data: ')) {
125
+ const jsonStr = line.slice(6);
126
+ if (jsonStr === '[DONE]') return;
127
+ try {
128
+ const chunk = JSON.parse(jsonStr);
129
+ const parsed = this.parseStreamChunk(chunk);
130
+ if (parsed) yield parsed;
131
+ } catch {
132
+ // Ignore parse errors for partial chunks
133
+ }
134
+ }
135
+ }
136
+ }
137
+ }
138
+
139
+ /**
140
+ * Convert messages to Gemini format
141
+ */
142
+ convertMessages(messages) {
143
+ return messages
144
+ .filter(m => m.role !== 'system')
145
+ .map(m => ({
146
+ role: m.role === 'assistant' ? 'model' : 'user',
147
+ parts: [{ text: m.content || '' }],
148
+ }));
149
+ }
150
+
151
+ /**
152
+ * Extract system instruction from messages
153
+ */
154
+ extractSystemInstruction(messages) {
155
+ const systemMsg = messages.find(m => m.role === 'system');
156
+ return systemMsg?.content || null;
157
+ }
158
+
159
+ /**
160
+ * Convert OpenAI-style tools to Gemini function declarations
161
+ */
162
+ convertTools(tools) {
163
+ return tools
164
+ .filter(t => t.type === 'function')
165
+ .map(t => ({
166
+ name: t.function.name,
167
+ description: t.function.description,
168
+ parameters: t.function.parameters,
169
+ }));
170
+ }
171
+
172
+ /**
173
+ * Convert tool choice to Gemini mode
174
+ */
175
+ convertToolChoice(choice) {
176
+ switch (choice) {
177
+ case 'none': return 'NONE';
178
+ case 'required': return 'ANY';
179
+ case 'auto':
180
+ default: return 'AUTO';
181
+ }
182
+ }
183
+
184
+ /**
185
+ * Parse full response
186
+ */
187
+ parseResponse(data) {
188
+ const candidate = data.candidates?.[0];
189
+ if (!candidate) {
190
+ throw new Error('No candidate in Gemini response');
191
+ }
192
+
193
+ const content = candidate.content?.parts?.[0]?.text || '';
194
+ const toolCalls = this.extractToolCalls(candidate);
195
+ const usage = data.usageMetadata ? {
196
+ promptTokens: data.usageMetadata.promptTokenCount || 0,
197
+ completionTokens: data.usageMetadata.candidatesTokenCount || 0,
198
+ totalTokens: data.usageMetadata.totalTokenCount || 0,
199
+ } : null;
200
+
201
+ return {
202
+ content,
203
+ toolCalls,
204
+ usage,
205
+ model: this.model,
206
+ provider: 'gemini',
207
+ };
208
+ }
209
+
210
+ /**
211
+ * Parse streaming chunk
212
+ */
213
+ parseStreamChunk(data) {
214
+ const candidate = data.candidates?.[0];
215
+ if (!candidate) return null;
216
+
217
+ const delta = candidate.content?.parts?.[0]?.text || '';
218
+ const toolCalls = this.extractToolCalls(candidate);
219
+
220
+ return {
221
+ content: delta,
222
+ toolCalls: toolCalls.length > 0 ? toolCalls : undefined,
223
+ usage: data.usageMetadata ? {
224
+ promptTokens: data.usageMetadata.promptTokenCount || 0,
225
+ completionTokens: data.usageMetadata.candidatesTokenCount || 0,
226
+ totalTokens: data.usageMetadata.totalTokenCount || 0,
227
+ } : undefined,
228
+ };
229
+ }
230
+
231
+ /**
232
+ * Extract tool calls from candidate
233
+ */
234
+ extractToolCalls(candidate) {
235
+ const functionCalls = candidate.content?.parts
236
+ ?.filter(p => p.functionCall)
237
+ .map(p => p.functionCall) || [];
238
+
239
+ return functionCalls.map((fc, i) => ({
240
+ id: `call_${Date.now()}_${i}`,
241
+ type: 'function',
242
+ function: {
243
+ name: fc.name,
244
+ arguments: JSON.stringify(fc.args || {}),
245
+ },
246
+ }));
247
+ }
248
+
249
+ /**
250
+ * Calculate estimated cost
251
+ */
252
+ calculateCost(usage) {
253
+ if (!usage) return 0;
254
+
255
+ // Approximate pricing (as of 2024) - update as needed
256
+ const pricing = {
257
+ 'gemini-1.5-flash': { input: 0.075, output: 0.30 }, // per 1M tokens
258
+ 'gemini-1.5-pro': { input: 3.50, output: 10.50 },
259
+ 'gemini-2.0-flash': { input: 0.075, output: 0.30 },
260
+ 'gemini-2.0-flash-lite': { input: 0.0375, output: 0.15 },
261
+ };
262
+
263
+ const modelPricing = pricing[this.model] || pricing['gemini-1.5-flash'];
264
+ const inputCost = (usage.promptTokens / 1_000_000) * modelPricing.input;
265
+ const outputCost = (usage.completionTokens / 1_000_000) * modelPricing.output;
266
+
267
+ return inputCost + outputCost;
268
+ }
269
+
270
+ /**
271
+ * Validate configuration
272
+ */
273
+ async validate() {
274
+ if (!this.apiKey) {
275
+ return { valid: false, error: 'Gemini API key is required' };
276
+ }
277
+
278
+ // Quick validation call
279
+ try {
280
+ const url = `${this.baseUrl}/models/${this.model}:generateContent?key=${this.apiKey}`;
281
+ const response = await fetch(url, {
282
+ method: 'POST',
283
+ headers: { 'Content-Type': 'application/json' },
284
+ body: JSON.stringify({
285
+ contents: [{ role: 'user', parts: [{ text: 'test' }] }],
286
+ generationConfig: { maxOutputTokens: 1 },
287
+ }),
288
+ });
289
+
290
+ if (!response.ok) {
291
+ const error = await response.json().catch(() => ({}));
292
+ return { valid: false, error: `Gemini validation failed: ${error.error?.message || response.statusText}` };
293
+ }
294
+
295
+ return { valid: true };
296
+ } catch (error) {
297
+ return { valid: false, error: `Gemini validation failed: ${error.message}` };
298
+ }
299
+ }
300
+
301
+ /**
302
+ * Check if provider supports function calling
303
+ */
304
+ supportsTools() {
305
+ return true;
306
+ }
307
+
308
+ /**
309
+ * Check if provider supports streaming
310
+ */
311
+ supportsStreaming() {
312
+ return true;
313
+ }
314
+
315
+ /**
316
+ * More accurate token estimation for Gemini
317
+ */
318
+ estimateTokens(messages) {
319
+ const text = messages.map(m => m.content || '').join(' ');
320
+ // Gemini uses similar tokenization to other models
321
+ return Math.ceil(text.length / 4);
322
+ }
323
+ }
@@ -134,8 +134,11 @@ export class ToolRegistry {
134
134
 
135
135
  // Execute with idempotency if enabled
136
136
  if (tool.idempotent && this.idempotencyStore) {
137
- const key = `${name}_${uuidv4()}`;
138
- return this.idempotencyStore.execute(key, name, params, () => tool.execute(params, context));
137
+ // BUG FIX: Use full request object (including tool name) for idempotency key
138
+ const requestForIdem = { name, params };
139
+ const requestHash = this.idempotencyStore.hashRequest(requestForIdem);
140
+ const key = `${name}_${requestHash}`;
141
+ return this.idempotencyStore.execute(key, name, requestForIdem, () => tool.execute(params, context));
139
142
  }
140
143
 
141
144
  return tool.execute(params, context);