@lukaplayground/aikit 1.0.0 → 1.0.1

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,1851 @@
1
+ (function (global, factory) {
2
+ typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory() :
3
+ typeof define === 'function' && define.amd ? define(factory) :
4
+ (global = typeof globalThis !== 'undefined' ? globalThis : global || self, global.AIKit = factory());
5
+ })(this, (function () { 'use strict';
6
+
7
+ /**
8
+ * CacheManager - LocalStorage based caching system
9
+ * @class
10
+ */
11
+ class CacheManager {
12
+ constructor(options = {}) {
13
+ this.prefix = options.prefix || 'aikit_cache_';
14
+ this.maxAge = options.maxAge || 3600000; // 1 hour default
15
+ this.maxSize = options.maxSize || 100; // Maximum cache entries
16
+
17
+ this.storage = typeof window !== 'undefined' && window.localStorage
18
+ ? window.localStorage
19
+ : new MemoryStorage();
20
+ }
21
+
22
+ /**
23
+ * Generate cache key from message and options
24
+ */
25
+ generateKey(message, options = {}) {
26
+ const normalized = {
27
+ message: message.trim().toLowerCase(),
28
+ ...options
29
+ };
30
+
31
+ const str = JSON.stringify(normalized);
32
+ return this.prefix + this.hashCode(str);
33
+ }
34
+
35
+ /**
36
+ * Simple hash function
37
+ * @private
38
+ */
39
+ hashCode(str) {
40
+ let hash = 0;
41
+ for (let i = 0; i < str.length; i++) {
42
+ const char = str.charCodeAt(i);
43
+ hash = ((hash << 5) - hash) + char;
44
+ hash = hash & hash; // Convert to 32bit integer
45
+ }
46
+ return Math.abs(hash).toString(36);
47
+ }
48
+
49
+ /**
50
+ * Get cached item
51
+ */
52
+ get(key) {
53
+ try {
54
+ const item = this.storage.getItem(key);
55
+ if (!item) return null;
56
+
57
+ const parsed = JSON.parse(item);
58
+ const now = Date.now();
59
+
60
+ // Check expiration
61
+ if (now - parsed.timestamp > this.maxAge) {
62
+ this.storage.removeItem(key);
63
+ return null;
64
+ }
65
+
66
+ return parsed.data;
67
+ } catch (error) {
68
+ console.warn('AIKit CacheManager: Error reading cache', error);
69
+ return null;
70
+ }
71
+ }
72
+
73
+ /**
74
+ * Set cache item
75
+ */
76
+ set(key, data) {
77
+ try {
78
+ // Check size limit
79
+ this.enforceMaxSize();
80
+
81
+ const item = {
82
+ data,
83
+ timestamp: Date.now()
84
+ };
85
+
86
+ this.storage.setItem(key, JSON.stringify(item));
87
+ return true;
88
+ } catch (error) {
89
+ console.warn('AIKit CacheManager: Error writing cache', error);
90
+ return false;
91
+ }
92
+ }
93
+
94
+ /**
95
+ * Enforce maximum cache size
96
+ * @private
97
+ */
98
+ enforceMaxSize() {
99
+ const keys = this.getKeys();
100
+
101
+ if (keys.length >= this.maxSize) {
102
+ // Remove oldest entries
103
+ const entries = keys.map(key => {
104
+ const item = this.storage.getItem(key);
105
+ const parsed = JSON.parse(item);
106
+ return { key, timestamp: parsed.timestamp };
107
+ });
108
+
109
+ entries.sort((a, b) => a.timestamp - b.timestamp);
110
+
111
+ // Remove oldest 20%
112
+ const toRemove = Math.ceil(this.maxSize * 0.2);
113
+ for (let i = 0; i < toRemove; i++) {
114
+ this.storage.removeItem(entries[i].key);
115
+ }
116
+ }
117
+ }
118
+
119
+ /**
120
+ * Get all cache keys
121
+ * @private
122
+ */
123
+ getKeys() {
124
+ const keys = [];
125
+ for (let i = 0; i < this.storage.length; i++) {
126
+ const key = this.storage.key(i);
127
+ if (key && key.startsWith(this.prefix)) {
128
+ keys.push(key);
129
+ }
130
+ }
131
+ return keys;
132
+ }
133
+
134
+ /**
135
+ * Clear all cache
136
+ */
137
+ clear() {
138
+ const keys = this.getKeys();
139
+ keys.forEach(key => this.storage.removeItem(key));
140
+ }
141
+
142
+ /**
143
+ * Get cache statistics
144
+ */
145
+ getStats() {
146
+ const keys = this.getKeys();
147
+ const now = Date.now();
148
+
149
+ let totalSize = 0;
150
+ let validEntries = 0;
151
+
152
+ keys.forEach(key => {
153
+ const item = this.storage.getItem(key);
154
+ if (item) {
155
+ totalSize += item.length;
156
+ const parsed = JSON.parse(item);
157
+ if (now - parsed.timestamp <= this.maxAge) {
158
+ validEntries++;
159
+ }
160
+ }
161
+ });
162
+
163
+ return {
164
+ totalEntries: keys.length,
165
+ validEntries,
166
+ expiredEntries: keys.length - validEntries,
167
+ totalSize,
168
+ maxAge: this.maxAge,
169
+ maxSize: this.maxSize
170
+ };
171
+ }
172
+ }
173
+
174
+ /**
175
+ * Memory-based storage fallback (for Node.js or browsers without localStorage)
176
+ * @class
177
+ */
178
+ class MemoryStorage {
179
+ constructor() {
180
+ this.storage = new Map();
181
+ }
182
+
183
+ getItem(key) {
184
+ return this.storage.get(key) || null;
185
+ }
186
+
187
+ setItem(key, value) {
188
+ this.storage.set(key, value);
189
+ }
190
+
191
+ removeItem(key) {
192
+ this.storage.delete(key);
193
+ }
194
+
195
+ key(index) {
196
+ return Array.from(this.storage.keys())[index];
197
+ }
198
+
199
+ get length() {
200
+ return this.storage.size;
201
+ }
202
+
203
+ clear() {
204
+ this.storage.clear();
205
+ }
206
+ }
207
+
208
+ /**
209
+ * CostTracker - Track API usage costs
210
+ * @class
211
+ */
212
+ class CostTracker {
213
+ constructor(options = {}) {
214
+ this.storageKey = options.storageKey || 'aikit_cost_tracker';
215
+ this.storage = typeof window !== 'undefined' && window.localStorage
216
+ ? window.localStorage
217
+ : new Map();
218
+
219
+ // 토큰당 가격 (USD) - 2024년 기준 근사치
220
+ this.pricing = {
221
+ openai: {
222
+ 'gpt-4': { input: 0.03 / 1000, output: 0.06 / 1000 },
223
+ 'gpt-4-turbo': { input: 0.01 / 1000, output: 0.03 / 1000 },
224
+ 'gpt-3.5-turbo': { input: 0.0005 / 1000, output: 0.0015 / 1000 }
225
+ },
226
+ claude: {
227
+ 'claude-3-opus': { input: 0.015 / 1000, output: 0.075 / 1000 },
228
+ 'claude-3-sonnet': { input: 0.003 / 1000, output: 0.015 / 1000 },
229
+ 'claude-3-haiku': { input: 0.00025 / 1000, output: 0.00125 / 1000 }
230
+ },
231
+ gemini: {
232
+ 'gemini-pro': { input: 0.00025 / 1000, output: 0.0005 / 1000 },
233
+ 'gemini-ultra': { input: 0.0005 / 1000, output: 0.001 / 1000 }
234
+ }
235
+ };
236
+
237
+ this.loadData();
238
+ }
239
+
240
+ /**
241
+ * Load existing tracking data
242
+ * @private
243
+ */
244
+ loadData() {
245
+ try {
246
+ if (this.storage instanceof Map) {
247
+ this.data = this.storage.get(this.storageKey) || this.initializeData();
248
+ } else {
249
+ const stored = this.storage.getItem(this.storageKey);
250
+ this.data = stored ? JSON.parse(stored) : this.initializeData();
251
+ }
252
+ } catch (error) {
253
+ console.warn('AIKit CostTracker: Error loading data', error);
254
+ this.data = this.initializeData();
255
+ }
256
+ }
257
+
258
+ /**
259
+ * Initialize empty data structure
260
+ * @private
261
+ */
262
+ initializeData() {
263
+ return {
264
+ total: 0,
265
+ byProvider: {},
266
+ byModel: {},
267
+ requests: [],
268
+ startDate: new Date().toISOString()
269
+ };
270
+ }
271
+
272
+ /**
273
+ * Save tracking data
274
+ * @private
275
+ */
276
+ saveData() {
277
+ try {
278
+ if (this.storage instanceof Map) {
279
+ this.storage.set(this.storageKey, this.data);
280
+ } else {
281
+ this.storage.setItem(this.storageKey, JSON.stringify(this.data));
282
+ }
283
+ } catch (error) {
284
+ console.warn('AIKit CostTracker: Error saving data', error);
285
+ }
286
+ }
287
+
288
+ /**
289
+ * Track a request
290
+ * @param {Object} request
291
+ */
292
+ track(request) {
293
+ const { provider, model, tokens = {}, timestamp } = request;
294
+
295
+ // Calculate cost
296
+ const cost = this.calculateCost(provider, model, tokens);
297
+
298
+ // Update totals
299
+ this.data.total += cost;
300
+
301
+ // Update by provider
302
+ if (!this.data.byProvider[provider]) {
303
+ this.data.byProvider[provider] = 0;
304
+ }
305
+ this.data.byProvider[provider] += cost;
306
+
307
+ // Update by model
308
+ const modelKey = `${provider}:${model || 'default'}`;
309
+ if (!this.data.byModel[modelKey]) {
310
+ this.data.byModel[modelKey] = 0;
311
+ }
312
+ this.data.byModel[modelKey] += cost;
313
+
314
+ // Add to request history (keep last 100)
315
+ this.data.requests.push({
316
+ provider,
317
+ model,
318
+ tokens,
319
+ cost,
320
+ timestamp: timestamp || new Date().toISOString()
321
+ });
322
+
323
+ if (this.data.requests.length > 100) {
324
+ this.data.requests.shift();
325
+ }
326
+
327
+ this.saveData();
328
+
329
+ return cost;
330
+ }
331
+
332
+ /**
333
+ * Calculate cost for a request
334
+ * @private
335
+ */
336
+ calculateCost(provider, model, tokens) {
337
+ const providerPricing = this.pricing[provider.toLowerCase()];
338
+ if (!providerPricing) {
339
+ console.warn(`AIKit CostTracker: Unknown provider '${provider}'`);
340
+ return 0;
341
+ }
342
+
343
+ // Find matching model pricing
344
+ let modelPricing;
345
+ if (model) {
346
+ const modelKey = Object.keys(providerPricing).find(key =>
347
+ model.toLowerCase().includes(key)
348
+ );
349
+ modelPricing = providerPricing[modelKey];
350
+ }
351
+
352
+ // Fallback to first available pricing
353
+ if (!modelPricing) {
354
+ modelPricing = Object.values(providerPricing)[0];
355
+ }
356
+
357
+ const inputCost = (tokens.input || tokens.prompt_tokens || 0) * modelPricing.input;
358
+ const outputCost = (tokens.output || tokens.completion_tokens || 0) * modelPricing.output;
359
+
360
+ return inputCost + outputCost;
361
+ }
362
+
363
+ /**
364
+ * Get cost report
365
+ */
366
+ getReport() {
367
+ const daysSinceStart = Math.ceil(
368
+ (new Date() - new Date(this.data.startDate)) / (1000 * 60 * 60 * 24)
369
+ );
370
+
371
+ return {
372
+ total: this.data.total.toFixed(4),
373
+ totalUSD: `$${this.data.total.toFixed(4)}`,
374
+ byProvider: Object.entries(this.data.byProvider).reduce((acc, [key, value]) => {
375
+ acc[key] = `$${value.toFixed(4)}`;
376
+ return acc;
377
+ }, {}),
378
+ byModel: Object.entries(this.data.byModel).reduce((acc, [key, value]) => {
379
+ acc[key] = `$${value.toFixed(4)}`;
380
+ return acc;
381
+ }, {}),
382
+ totalRequests: this.data.requests.length,
383
+ averageCostPerRequest: this.data.requests.length > 0
384
+ ? `$${(this.data.total / this.data.requests.length).toFixed(6)}`
385
+ : '$0',
386
+ dailyAverage: `$${(this.data.total / Math.max(daysSinceStart, 1)).toFixed(4)}`,
387
+ startDate: this.data.startDate,
388
+ lastRequest: this.data.requests.length > 0
389
+ ? this.data.requests[this.data.requests.length - 1].timestamp
390
+ : null
391
+ };
392
+ }
393
+
394
+ /**
395
+ * Get recent requests
396
+ */
397
+ getRecentRequests(limit = 10) {
398
+ return this.data.requests.slice(-limit).reverse();
399
+ }
400
+
401
+ /**
402
+ * Reset all tracking data
403
+ */
404
+ reset() {
405
+ this.data = this.initializeData();
406
+ this.saveData();
407
+ }
408
+
409
+ /**
410
+ * Update pricing for a provider/model
411
+ */
412
+ updatePricing(provider, model, inputPrice, outputPrice) {
413
+ if (!this.pricing[provider]) {
414
+ this.pricing[provider] = {};
415
+ }
416
+
417
+ this.pricing[provider][model] = {
418
+ input: inputPrice,
419
+ output: outputPrice
420
+ };
421
+ }
422
+
423
+ /**
424
+ * Export data as JSON
425
+ */
426
+ exportData() {
427
+ return JSON.stringify(this.data, null, 2);
428
+ }
429
+
430
+ /**
431
+ * Import data from JSON
432
+ */
433
+ importData(jsonString) {
434
+ try {
435
+ this.data = JSON.parse(jsonString);
436
+ this.saveData();
437
+ return true;
438
+ } catch (error) {
439
+ console.error('AIKit CostTracker: Error importing data', error);
440
+ return false;
441
+ }
442
+ }
443
+ }
444
+
445
+ /**
446
+ * ResponseValidator - Validate AI responses based on rules
447
+ * QA-focused validation layer
448
+ * @class
449
+ */
450
+ class ResponseValidator {
451
+ constructor() {
452
+ this.rules = new Map();
453
+ this.loadDefaultRules();
454
+ }
455
+
456
+ /**
457
+ * Load default validation rules
458
+ * @private
459
+ */
460
+ loadDefaultRules() {
461
+ // Length validation
462
+ this.addRule('maxLength', (response, limit) => {
463
+ const text = this.extractText(response);
464
+ if (text.length > limit) {
465
+ return {
466
+ valid: false,
467
+ error: `Response exceeds maximum length (${text.length} > ${limit})`
468
+ };
469
+ }
470
+ return { valid: true };
471
+ });
472
+
473
+ this.addRule('minLength', (response, limit) => {
474
+ const text = this.extractText(response);
475
+ if (text.length < limit) {
476
+ return {
477
+ valid: false,
478
+ error: `Response below minimum length (${text.length} < ${limit})`
479
+ };
480
+ }
481
+ return { valid: true };
482
+ });
483
+
484
+ // Content validation
485
+ this.addRule('mustInclude', (response, keywords) => {
486
+ const text = this.extractText(response).toLowerCase();
487
+ const missing = keywords.filter(kw => !text.includes(kw.toLowerCase()));
488
+
489
+ if (missing.length > 0) {
490
+ return {
491
+ valid: false,
492
+ error: `Response missing required keywords: ${missing.join(', ')}`
493
+ };
494
+ }
495
+ return { valid: true };
496
+ });
497
+
498
+ this.addRule('mustNotInclude', (response, keywords) => {
499
+ const text = this.extractText(response).toLowerCase();
500
+ const found = keywords.filter(kw => text.includes(kw.toLowerCase()));
501
+
502
+ if (found.length > 0) {
503
+ return {
504
+ valid: false,
505
+ error: `Response contains forbidden keywords: ${found.join(', ')}`
506
+ };
507
+ }
508
+ return { valid: true };
509
+ });
510
+
511
+ // Format validation
512
+ this.addRule('format', (response, format) => {
513
+ const text = this.extractText(response);
514
+
515
+ const formats = {
516
+ json: () => {
517
+ try {
518
+ JSON.parse(text);
519
+ return { valid: true };
520
+ } catch (e) {
521
+ return { valid: false, error: 'Response is not valid JSON' };
522
+ }
523
+ },
524
+ email: () => {
525
+ const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
526
+ const hasEmail = emailRegex.test(text);
527
+ return hasEmail
528
+ ? { valid: true }
529
+ : { valid: false, error: 'Response does not contain valid email' };
530
+ },
531
+ url: () => {
532
+ const urlRegex = /(https?:\/\/[^\s]+)/g;
533
+ const hasUrl = urlRegex.test(text);
534
+ return hasUrl
535
+ ? { valid: true }
536
+ : { valid: false, error: 'Response does not contain valid URL' };
537
+ },
538
+ number: () => {
539
+ const hasNumber = /\d+/.test(text);
540
+ return hasNumber
541
+ ? { valid: true }
542
+ : { valid: false, error: 'Response does not contain numbers' };
543
+ },
544
+ markdown: () => {
545
+ const hasMarkdown = /[#*_`\[\]]/.test(text);
546
+ return hasMarkdown
547
+ ? { valid: true }
548
+ : { valid: false, error: 'Response does not contain markdown formatting' };
549
+ }
550
+ };
551
+
552
+ const validator = formats[format.toLowerCase()];
553
+ if (!validator) {
554
+ return { valid: false, error: `Unknown format: ${format}` };
555
+ }
556
+
557
+ return validator();
558
+ });
559
+
560
+ // Language validation
561
+ this.addRule('language', (response, lang) => {
562
+ const text = this.extractText(response);
563
+
564
+ const patterns = {
565
+ korean: /[가-힣]/,
566
+ english: /[a-zA-Z]/,
567
+ japanese: /[ぁ-んァ-ン]/,
568
+ chinese: /[\u4e00-\u9fff]/,
569
+ numbers: /\d/
570
+ };
571
+
572
+ const pattern = patterns[lang.toLowerCase()];
573
+ if (!pattern) {
574
+ return { valid: false, error: `Unknown language: ${lang}` };
575
+ }
576
+
577
+ if (!pattern.test(text)) {
578
+ return {
579
+ valid: false,
580
+ error: `Response does not contain ${lang} characters`
581
+ };
582
+ }
583
+
584
+ return { valid: true };
585
+ });
586
+
587
+ // Sentiment validation
588
+ this.addRule('sentiment', (response, expectedSentiment) => {
589
+ const text = this.extractText(response).toLowerCase();
590
+
591
+ const sentimentWords = {
592
+ positive: ['good', 'great', 'excellent', 'amazing', 'wonderful', 'fantastic', '좋', '훌륭', '멋진'],
593
+ negative: ['bad', 'poor', 'terrible', 'awful', 'horrible', '나쁜', '끔찍', '최악'],
594
+ neutral: ['okay', 'fine', 'acceptable', '괜찮', '무난']
595
+ };
596
+
597
+ const words = sentimentWords[expectedSentiment.toLowerCase()] || [];
598
+ const hasSentiment = words.some(word => text.includes(word));
599
+
600
+ if (!hasSentiment) {
601
+ return {
602
+ valid: false,
603
+ error: `Response does not match expected sentiment: ${expectedSentiment}`
604
+ };
605
+ }
606
+
607
+ return { valid: true };
608
+ });
609
+
610
+ // Custom regex validation
611
+ this.addRule('regex', (response, pattern) => {
612
+ const text = this.extractText(response);
613
+ const regex = new RegExp(pattern);
614
+
615
+ if (!regex.test(text)) {
616
+ return {
617
+ valid: false,
618
+ error: `Response does not match pattern: ${pattern}`
619
+ };
620
+ }
621
+
622
+ return { valid: true };
623
+ });
624
+ }
625
+
626
+ /**
627
+ * Extract text from response object
628
+ * @private
629
+ */
630
+ extractText(response) {
631
+ if (typeof response === 'string') {
632
+ return response;
633
+ }
634
+
635
+ if (response.text) return response.text;
636
+ if (response.content) return response.content;
637
+ if (response.message) return response.message;
638
+
639
+ return JSON.stringify(response);
640
+ }
641
+
642
+ /**
643
+ * Add custom validation rule
644
+ */
645
+ addRule(name, validator) {
646
+ this.rules.set(name, validator);
647
+ }
648
+
649
+ /**
650
+ * Remove validation rule
651
+ */
652
+ removeRule(name) {
653
+ this.rules.delete(name);
654
+ }
655
+
656
+ /**
657
+ * Validate response against rules
658
+ */
659
+ validate(response, validationConfig) {
660
+ const errors = [];
661
+
662
+ for (const [ruleName, ruleValue] of Object.entries(validationConfig)) {
663
+ const validator = this.rules.get(ruleName);
664
+
665
+ if (!validator) {
666
+ console.warn(`AIKit Validator: Unknown rule '${ruleName}'`);
667
+ continue;
668
+ }
669
+
670
+ const result = validator(response, ruleValue);
671
+
672
+ if (!result.valid) {
673
+ errors.push(result.error);
674
+ }
675
+ }
676
+
677
+ return {
678
+ isValid: errors.length === 0,
679
+ errors,
680
+ validatedAt: new Date().toISOString()
681
+ };
682
+ }
683
+
684
+ /**
685
+ * Validate with custom function
686
+ */
687
+ validateCustom(response, customFn) {
688
+ try {
689
+ const result = customFn(response);
690
+ return {
691
+ isValid: result === true || (result && result.valid === true),
692
+ errors: result.errors || (result === false ? ['Custom validation failed'] : []),
693
+ validatedAt: new Date().toISOString()
694
+ };
695
+ } catch (error) {
696
+ return {
697
+ isValid: false,
698
+ errors: [`Custom validation error: ${error.message}`],
699
+ validatedAt: new Date().toISOString()
700
+ };
701
+ }
702
+ }
703
+
704
+ /**
705
+ * Get available validation rules
706
+ */
707
+ getAvailableRules() {
708
+ return Array.from(this.rules.keys());
709
+ }
710
+ }
711
+
712
+ /**
713
+ * BaseAdapter - Base class for all AI provider adapters
714
+ * Defines the interface that all adapters must implement
715
+ * @class
716
+ */
717
+ class BaseAdapter {
718
+ constructor() {
719
+ if (new.target === BaseAdapter) {
720
+ throw new Error('BaseAdapter is an abstract class and cannot be instantiated directly');
721
+ }
722
+ }
723
+
724
+ /**
725
+ * Send chat request to AI provider
726
+ * Must be implemented by subclasses
727
+ * @abstract
728
+ * @param {Object} request - Request object
729
+ * @param {string} request.message - User message
730
+ * @param {string} request.apiKey - API key
731
+ * @param {Object} request.options - Additional options
732
+ * @returns {Promise<Object>} Normalized response
733
+ */
734
+ async chat(request) {
735
+ throw new Error('chat() must be implemented by subclass');
736
+ }
737
+
738
+ /**
739
+ * Normalize response to standard format
740
+ * @protected
741
+ * @param {Object} rawResponse - Raw API response
742
+ * @returns {Object} Normalized response
743
+ */
744
+ normalizeResponse(rawResponse) {
745
+ return {
746
+ text: this.extractText(rawResponse),
747
+ raw: rawResponse,
748
+ usage: this.extractUsage(rawResponse),
749
+ model: this.extractModel(rawResponse),
750
+ finishReason: this.extractFinishReason(rawResponse)
751
+ };
752
+ }
753
+
754
+ /**
755
+ * Extract text from raw response
756
+ * @abstract
757
+ * @protected
758
+ */
759
+ extractText(rawResponse) {
760
+ throw new Error('extractText() must be implemented by subclass');
761
+ }
762
+
763
+ /**
764
+ * Extract usage information from raw response
765
+ * @abstract
766
+ * @protected
767
+ */
768
+ extractUsage(rawResponse) {
769
+ throw new Error('extractUsage() must be implemented by subclass');
770
+ }
771
+
772
+ /**
773
+ * Extract model name from raw response
774
+ * @abstract
775
+ * @protected
776
+ */
777
+ extractModel(rawResponse) {
778
+ throw new Error('extractModel() must be implemented by subclass');
779
+ }
780
+
781
+ /**
782
+ * Extract finish reason from raw response
783
+ * @abstract
784
+ * @protected
785
+ */
786
+ extractFinishReason(rawResponse) {
787
+ throw new Error('extractFinishReason() must be implemented by subclass');
788
+ }
789
+
790
+ /**
791
+ * Build request headers
792
+ * @protected
793
+ * @param {string} apiKey - API key
794
+ * @returns {Object} Headers object
795
+ */
796
+ buildHeaders(apiKey) {
797
+ return {
798
+ 'Content-Type': 'application/json',
799
+ 'Authorization': `Bearer ${apiKey}`
800
+ };
801
+ }
802
+
803
+ /**
804
+ * Make HTTP request
805
+ * @protected
806
+ * @param {string} url - API endpoint URL
807
+ * @param {Object} options - Fetch options
808
+ * @returns {Promise<Object>} Response data
809
+ */
810
+ async makeRequest(url, options) {
811
+ try {
812
+ const response = await fetch(url, options);
813
+
814
+ if (!response.ok) {
815
+ const errorData = await response.json().catch(() => ({}));
816
+ throw new Error(
817
+ `HTTP ${response.status}: ${errorData.error?.message || response.statusText}`
818
+ );
819
+ }
820
+
821
+ return await response.json();
822
+ } catch (error) {
823
+ if (error.name === 'TypeError' && error.message.includes('fetch')) {
824
+ throw new Error('Network error: Unable to reach API endpoint');
825
+ }
826
+ throw error;
827
+ }
828
+ }
829
+
830
+ /**
831
+ * Validate request parameters
832
+ * @protected
833
+ * @param {Object} request - Request object
834
+ */
835
+ validateRequest(request) {
836
+ if (!request.message || typeof request.message !== 'string') {
837
+ throw new Error('Message is required and must be a string');
838
+ }
839
+
840
+ if (!request.apiKey || typeof request.apiKey !== 'string') {
841
+ throw new Error('API key is required and must be a string');
842
+ }
843
+
844
+ if (request.message.trim().length === 0) {
845
+ throw new Error('Message cannot be empty');
846
+ }
847
+ }
848
+
849
+ /**
850
+ * Handle rate limiting with exponential backoff
851
+ * @protected
852
+ * @param {Function} fn - Function to retry
853
+ * @param {number} maxRetries - Maximum retry attempts
854
+ * @returns {Promise<any>} Function result
855
+ */
856
+ async withRetry(fn, maxRetries = 3) {
857
+ for (let i = 0; i < maxRetries; i++) {
858
+ try {
859
+ return await fn();
860
+ } catch (error) {
861
+ if (i === maxRetries - 1) throw error;
862
+
863
+ // Check if it's a rate limit error
864
+ const isRateLimit = error.message.includes('429') ||
865
+ error.message.includes('rate limit');
866
+
867
+ if (!isRateLimit) throw error;
868
+
869
+ // Exponential backoff
870
+ const delay = Math.pow(2, i) * 1000;
871
+ await new Promise(resolve => setTimeout(resolve, delay));
872
+ }
873
+ }
874
+ }
875
+
876
+ /**
877
+ * Get provider name
878
+ * @returns {string} Provider name
879
+ */
880
+ getProviderName() {
881
+ return this.constructor.name.replace('Adapter', '').toLowerCase();
882
+ }
883
+ }
884
+
885
+ /**
886
+ * OpenAIAdapter - Adapter for OpenAI API
887
+ * @class
888
+ * @extends BaseAdapter
889
+ */
890
+
891
+ class OpenAIAdapter extends BaseAdapter {
892
+ constructor() {
893
+ super();
894
+ this.baseURL = 'https://api.openai.com/v1';
895
+ this.defaultModel = 'gpt-3.5-turbo';
896
+ }
897
+
898
+ /**
899
+ * Send chat request to OpenAI
900
+ * @override
901
+ */
902
+ async chat(request) {
903
+ this.validateRequest(request);
904
+
905
+ const { message, apiKey, options = {} } = request;
906
+
907
+ const requestBody = {
908
+ model: options.model || this.defaultModel,
909
+ messages: this.buildMessages(message, options),
910
+ temperature: options.temperature ?? 0.7,
911
+ max_tokens: options.maxTokens || options.max_tokens,
912
+ top_p: options.topP || options.top_p,
913
+ frequency_penalty: options.frequencyPenalty || options.frequency_penalty,
914
+ presence_penalty: options.presencePenalty || options.presence_penalty,
915
+ stream: false
916
+ };
917
+
918
+ // Remove undefined values
919
+ Object.keys(requestBody).forEach(key => {
920
+ if (requestBody[key] === undefined) {
921
+ delete requestBody[key];
922
+ }
923
+ });
924
+
925
+ const fetchOptions = {
926
+ method: 'POST',
927
+ headers: this.buildHeaders(apiKey),
928
+ body: JSON.stringify(requestBody)
929
+ };
930
+
931
+ const rawResponse = await this.withRetry(() =>
932
+ this.makeRequest(`${this.baseURL}/chat/completions`, fetchOptions)
933
+ );
934
+
935
+ return this.normalizeResponse(rawResponse);
936
+ }
937
+
938
+ /**
939
+ * Build messages array from user message
940
+ * @private
941
+ */
942
+ buildMessages(message, options) {
943
+ const messages = [];
944
+
945
+ // Add system message if provided
946
+ if (options.systemMessage) {
947
+ messages.push({
948
+ role: 'system',
949
+ content: options.systemMessage
950
+ });
951
+ }
952
+
953
+ // Add conversation history if provided
954
+ if (options.history && Array.isArray(options.history)) {
955
+ messages.push(...options.history);
956
+ }
957
+
958
+ // Add current user message
959
+ messages.push({
960
+ role: 'user',
961
+ content: message
962
+ });
963
+
964
+ return messages;
965
+ }
966
+
967
+ /**
968
+ * Extract text from OpenAI response
969
+ * @override
970
+ */
971
+ extractText(rawResponse) {
972
+ if (!rawResponse.choices || rawResponse.choices.length === 0) {
973
+ throw new Error('Invalid OpenAI response: no choices returned');
974
+ }
975
+
976
+ const firstChoice = rawResponse.choices[0];
977
+ return firstChoice.message?.content || '';
978
+ }
979
+
980
+ /**
981
+ * Extract usage information
982
+ * @override
983
+ */
984
+ extractUsage(rawResponse) {
985
+ const usage = rawResponse.usage || {};
986
+
987
+ return {
988
+ promptTokens: usage.prompt_tokens || 0,
989
+ completionTokens: usage.completion_tokens || 0,
990
+ totalTokens: usage.total_tokens || 0,
991
+ // Normalized names for compatibility
992
+ input: usage.prompt_tokens || 0,
993
+ output: usage.completion_tokens || 0,
994
+ total: usage.total_tokens || 0
995
+ };
996
+ }
997
+
998
+ /**
999
+ * Extract model name
1000
+ * @override
1001
+ */
1002
+ extractModel(rawResponse) {
1003
+ return rawResponse.model || this.defaultModel;
1004
+ }
1005
+
1006
+ /**
1007
+ * Extract finish reason
1008
+ * @override
1009
+ */
1010
+ extractFinishReason(rawResponse) {
1011
+ if (!rawResponse.choices || rawResponse.choices.length === 0) {
1012
+ return 'unknown';
1013
+ }
1014
+
1015
+ return rawResponse.choices[0].finish_reason || 'unknown';
1016
+ }
1017
+
1018
+ /**
1019
+ * Stream chat (for future implementation)
1020
+ * @param {Object} request - Request object
1021
+ * @param {Function} onChunk - Callback for each chunk
1022
+ */
1023
+ async streamChat(request, onChunk) {
1024
+ // TODO: Implement streaming support
1025
+ throw new Error('Streaming is not yet implemented for OpenAI adapter');
1026
+ }
1027
+
1028
+ /**
1029
+ * List available models
1030
+ * @param {string} apiKey - API key
1031
+ */
1032
+ async listModels(apiKey) {
1033
+ const fetchOptions = {
1034
+ method: 'GET',
1035
+ headers: this.buildHeaders(apiKey)
1036
+ };
1037
+
1038
+ const response = await this.makeRequest(`${this.baseURL}/models`, fetchOptions);
1039
+
1040
+ // Filter to chat models only
1041
+ const chatModels = response.data.filter(model =>
1042
+ model.id.includes('gpt') || model.id.includes('turbo')
1043
+ );
1044
+
1045
+ return chatModels.map(model => ({
1046
+ id: model.id,
1047
+ created: model.created,
1048
+ ownedBy: model.owned_by
1049
+ }));
1050
+ }
1051
+
1052
+ /**
1053
+ * Generate embeddings
1054
+ * @param {string} text - Text to embed
1055
+ * @param {string} apiKey - API key
1056
+ * @param {Object} options - Options
1057
+ */
1058
+ async createEmbedding(text, apiKey, options = {}) {
1059
+ const requestBody = {
1060
+ model: options.model || 'text-embedding-ada-002',
1061
+ input: text
1062
+ };
1063
+
1064
+ const fetchOptions = {
1065
+ method: 'POST',
1066
+ headers: this.buildHeaders(apiKey),
1067
+ body: JSON.stringify(requestBody)
1068
+ };
1069
+
1070
+ const response = await this.makeRequest(`${this.baseURL}/embeddings`, fetchOptions);
1071
+
1072
+ return {
1073
+ embedding: response.data[0].embedding,
1074
+ model: response.model,
1075
+ usage: response.usage
1076
+ };
1077
+ }
1078
+ }
1079
+
1080
+ /**
1081
+ * ClaudeAdapter - Adapter for Anthropic Claude API
1082
+ * @class
1083
+ * @extends BaseAdapter
1084
+ */
1085
+
1086
+ class ClaudeAdapter extends BaseAdapter {
1087
+ constructor() {
1088
+ super();
1089
+ this.baseURL = 'https://api.anthropic.com/v1';
1090
+ this.defaultModel = 'claude-3-sonnet-20240229';
1091
+ this.apiVersion = '2023-06-01';
1092
+ }
1093
+
1094
+ /**
1095
+ * Send chat request to Claude
1096
+ * @override
1097
+ */
1098
+ async chat(request) {
1099
+ this.validateRequest(request);
1100
+
1101
+ const { message, apiKey, options = {} } = request;
1102
+
1103
+ const requestBody = {
1104
+ model: options.model || this.defaultModel,
1105
+ messages: this.buildMessages(message, options),
1106
+ max_tokens: options.maxTokens || options.max_tokens || 1024,
1107
+ temperature: options.temperature ?? 1.0,
1108
+ top_p: options.topP || options.top_p,
1109
+ top_k: options.topK || options.top_k,
1110
+ stream: false
1111
+ };
1112
+
1113
+ // Add system message if provided
1114
+ if (options.systemMessage) {
1115
+ requestBody.system = options.systemMessage;
1116
+ }
1117
+
1118
+ // Remove undefined values
1119
+ Object.keys(requestBody).forEach(key => {
1120
+ if (requestBody[key] === undefined) {
1121
+ delete requestBody[key];
1122
+ }
1123
+ });
1124
+
1125
+ const fetchOptions = {
1126
+ method: 'POST',
1127
+ headers: this.buildHeaders(apiKey),
1128
+ body: JSON.stringify(requestBody)
1129
+ };
1130
+
1131
+ const rawResponse = await this.withRetry(() =>
1132
+ this.makeRequest(`${this.baseURL}/messages`, fetchOptions)
1133
+ );
1134
+
1135
+ return this.normalizeResponse(rawResponse);
1136
+ }
1137
+
1138
+ /**
1139
+ * Build headers for Claude API
1140
+ * @override
1141
+ */
1142
+ buildHeaders(apiKey) {
1143
+ return {
1144
+ 'Content-Type': 'application/json',
1145
+ 'x-api-key': apiKey,
1146
+ 'anthropic-version': this.apiVersion
1147
+ };
1148
+ }
1149
+
1150
+ /**
1151
+ * Build messages array from user message
1152
+ * @private
1153
+ */
1154
+ buildMessages(message, options) {
1155
+ const messages = [];
1156
+
1157
+ // Add conversation history if provided
1158
+ if (options.history && Array.isArray(options.history)) {
1159
+ // Claude uses 'user' and 'assistant' roles
1160
+ messages.push(...options.history.map(msg => ({
1161
+ role: msg.role === 'system' ? 'user' : msg.role,
1162
+ content: msg.content
1163
+ })));
1164
+ }
1165
+
1166
+ // Add current user message
1167
+ messages.push({
1168
+ role: 'user',
1169
+ content: message
1170
+ });
1171
+
1172
+ return messages;
1173
+ }
1174
+
1175
+ /**
1176
+ * Extract text from Claude response
1177
+ * @override
1178
+ */
1179
+ extractText(rawResponse) {
1180
+ if (!rawResponse.content || rawResponse.content.length === 0) {
1181
+ throw new Error('Invalid Claude response: no content returned');
1182
+ }
1183
+
1184
+ // Claude can return multiple content blocks
1185
+ const textBlocks = rawResponse.content
1186
+ .filter(block => block.type === 'text')
1187
+ .map(block => block.text);
1188
+
1189
+ return textBlocks.join('\n');
1190
+ }
1191
+
1192
+ /**
1193
+ * Extract usage information
1194
+ * @override
1195
+ */
1196
+ extractUsage(rawResponse) {
1197
+ const usage = rawResponse.usage || {};
1198
+
1199
+ return {
1200
+ promptTokens: usage.input_tokens || 0,
1201
+ completionTokens: usage.output_tokens || 0,
1202
+ totalTokens: (usage.input_tokens || 0) + (usage.output_tokens || 0),
1203
+ // Normalized names for compatibility
1204
+ input: usage.input_tokens || 0,
1205
+ output: usage.output_tokens || 0,
1206
+ total: (usage.input_tokens || 0) + (usage.output_tokens || 0)
1207
+ };
1208
+ }
1209
+
1210
+ /**
1211
+ * Extract model name
1212
+ * @override
1213
+ */
1214
+ extractModel(rawResponse) {
1215
+ return rawResponse.model || this.defaultModel;
1216
+ }
1217
+
1218
+ /**
1219
+ * Extract finish reason
1220
+ * @override
1221
+ */
1222
+ extractFinishReason(rawResponse) {
1223
+ return rawResponse.stop_reason || 'unknown';
1224
+ }
1225
+
1226
+ /**
1227
+ * Stream chat (for future implementation)
1228
+ * @param {Object} request - Request object
1229
+ * @param {Function} onChunk - Callback for each chunk
1230
+ */
1231
+ async streamChat(request, onChunk) {
1232
+ // TODO: Implement streaming support
1233
+ throw new Error('Streaming is not yet implemented for Claude adapter');
1234
+ }
1235
+
1236
+ /**
1237
+ * Get model information
1238
+ */
1239
+ getAvailableModels() {
1240
+ return [
1241
+ {
1242
+ id: 'claude-3-opus-20240229',
1243
+ name: 'Claude 3 Opus',
1244
+ description: 'Most powerful model, best for complex tasks',
1245
+ maxTokens: 4096
1246
+ },
1247
+ {
1248
+ id: 'claude-3-sonnet-20240229',
1249
+ name: 'Claude 3 Sonnet',
1250
+ description: 'Balanced performance and speed',
1251
+ maxTokens: 4096
1252
+ },
1253
+ {
1254
+ id: 'claude-3-haiku-20240307',
1255
+ name: 'Claude 3 Haiku',
1256
+ description: 'Fastest model, best for simple tasks',
1257
+ maxTokens: 4096
1258
+ },
1259
+ {
1260
+ id: 'claude-2.1',
1261
+ name: 'Claude 2.1',
1262
+ description: 'Previous generation model',
1263
+ maxTokens: 4096
1264
+ }
1265
+ ];
1266
+ }
1267
+
1268
+ /**
1269
+ * Count tokens (approximate)
1270
+ * Claude's tokenization is similar to GPT but not identical
1271
+ * This is a rough estimate
1272
+ */
1273
+ estimateTokens(text) {
1274
+ // Rough estimate: ~4 characters per token for English
1275
+ // ~2 characters per token for code
1276
+ const avgCharsPerToken = text.match(/[a-zA-Z0-9_]/g) ? 4 : 3;
1277
+ return Math.ceil(text.length / avgCharsPerToken);
1278
+ }
1279
+ }
1280
+
1281
+ /**
1282
+ * GeminiAdapter - Adapter for Google Gemini API
1283
+ * @class
1284
+ * @extends BaseAdapter
1285
+ */
1286
+
1287
+ class GeminiAdapter extends BaseAdapter {
1288
+ constructor() {
1289
+ super();
1290
+ this.baseURL = 'https://generativelanguage.googleapis.com/v1beta';
1291
+ this.defaultModel = 'gemini-pro';
1292
+ }
1293
+
1294
+ /**
1295
+ * Send chat request to Gemini
1296
+ * @override
1297
+ */
1298
+ async chat(request) {
1299
+ this.validateRequest(request);
1300
+
1301
+ const { message, apiKey, options = {} } = request;
1302
+ const model = options.model || this.defaultModel;
1303
+
1304
+ const requestBody = {
1305
+ contents: this.buildContents(message, options),
1306
+ generationConfig: {
1307
+ temperature: options.temperature ?? 0.9,
1308
+ topK: options.topK || options.top_k,
1309
+ topP: (options.topP || options.top_p) ?? 1,
1310
+ maxOutputTokens: options.maxTokens || options.max_tokens || options.maxOutputTokens,
1311
+ stopSequences: options.stopSequences || options.stop
1312
+ }
1313
+ };
1314
+
1315
+ // Add safety settings if provided
1316
+ if (options.safetySettings) {
1317
+ requestBody.safetySettings = options.safetySettings;
1318
+ }
1319
+
1320
+ // Remove undefined values
1321
+ if (requestBody.generationConfig) {
1322
+ Object.keys(requestBody.generationConfig).forEach(key => {
1323
+ if (requestBody.generationConfig[key] === undefined) {
1324
+ delete requestBody.generationConfig[key];
1325
+ }
1326
+ });
1327
+ }
1328
+
1329
+ const url = `${this.baseURL}/models/${model}:generateContent?key=${apiKey}`;
1330
+
1331
+ const fetchOptions = {
1332
+ method: 'POST',
1333
+ headers: {
1334
+ 'Content-Type': 'application/json'
1335
+ },
1336
+ body: JSON.stringify(requestBody)
1337
+ };
1338
+
1339
+ const rawResponse = await this.withRetry(() =>
1340
+ this.makeRequest(url, fetchOptions)
1341
+ );
1342
+
1343
+ return this.normalizeResponse(rawResponse);
1344
+ }
1345
+
1346
+ /**
1347
+ * Build contents array from user message
1348
+ * @private
1349
+ */
1350
+ buildContents(message, options) {
1351
+ const contents = [];
1352
+
1353
+ // Add conversation history if provided
1354
+ if (options.history && Array.isArray(options.history)) {
1355
+ options.history.forEach(msg => {
1356
+ contents.push({
1357
+ role: msg.role === 'assistant' ? 'model' : 'user',
1358
+ parts: [{ text: msg.content }]
1359
+ });
1360
+ });
1361
+ }
1362
+
1363
+ // Add current user message
1364
+ contents.push({
1365
+ role: 'user',
1366
+ parts: [{ text: message }]
1367
+ });
1368
+
1369
+ return contents;
1370
+ }
1371
+
1372
+ /**
1373
+ * Extract text from Gemini response
1374
+ * @override
1375
+ */
1376
+ extractText(rawResponse) {
1377
+ if (!rawResponse.candidates || rawResponse.candidates.length === 0) {
1378
+ throw new Error('Invalid Gemini response: no candidates returned');
1379
+ }
1380
+
1381
+ const candidate = rawResponse.candidates[0];
1382
+
1383
+ if (!candidate.content || !candidate.content.parts) {
1384
+ throw new Error('Invalid Gemini response: no content parts');
1385
+ }
1386
+
1387
+ // Combine all text parts
1388
+ const textParts = candidate.content.parts
1389
+ .filter(part => part.text)
1390
+ .map(part => part.text);
1391
+
1392
+ return textParts.join('\n');
1393
+ }
1394
+
1395
+ /**
1396
+ * Extract usage information
1397
+ * @override
1398
+ */
1399
+ extractUsage(rawResponse) {
1400
+ const metadata = rawResponse.usageMetadata || {};
1401
+
1402
+ return {
1403
+ promptTokens: metadata.promptTokenCount || 0,
1404
+ completionTokens: metadata.candidatesTokenCount || 0,
1405
+ totalTokens: metadata.totalTokenCount || 0,
1406
+ // Normalized names for compatibility
1407
+ input: metadata.promptTokenCount || 0,
1408
+ output: metadata.candidatesTokenCount || 0,
1409
+ total: metadata.totalTokenCount || 0
1410
+ };
1411
+ }
1412
+
1413
+ /**
1414
+ * Extract model name
1415
+ * @override
1416
+ */
1417
+ extractModel(rawResponse) {
1418
+ return rawResponse.modelVersion || this.defaultModel;
1419
+ }
1420
+
1421
+ /**
1422
+ * Extract finish reason
1423
+ * @override
1424
+ */
1425
+ extractFinishReason(rawResponse) {
1426
+ if (!rawResponse.candidates || rawResponse.candidates.length === 0) {
1427
+ return 'unknown';
1428
+ }
1429
+
1430
+ return rawResponse.candidates[0].finishReason || 'STOP';
1431
+ }
1432
+
1433
+ /**
1434
+ * Stream chat (for future implementation)
1435
+ * @param {Object} request - Request object
1436
+ * @param {Function} onChunk - Callback for each chunk
1437
+ */
1438
+ async streamChat(request, onChunk) {
1439
+ // TODO: Implement streaming support
1440
+ throw new Error('Streaming is not yet implemented for Gemini adapter');
1441
+ }
1442
+
1443
+ /**
1444
+ * Get available models
1445
+ */
1446
+ getAvailableModels() {
1447
+ return [
1448
+ {
1449
+ id: 'gemini-pro',
1450
+ name: 'Gemini Pro',
1451
+ description: 'Best for text generation',
1452
+ maxTokens: 8192
1453
+ },
1454
+ {
1455
+ id: 'gemini-pro-vision',
1456
+ name: 'Gemini Pro Vision',
1457
+ description: 'Multimodal model for text and images',
1458
+ maxTokens: 4096
1459
+ },
1460
+ {
1461
+ id: 'gemini-ultra',
1462
+ name: 'Gemini Ultra',
1463
+ description: 'Most capable model (limited access)',
1464
+ maxTokens: 8192
1465
+ }
1466
+ ];
1467
+ }
1468
+
1469
+ /**
1470
+ * List models via API
1471
+ * @param {string} apiKey - API key
1472
+ */
1473
+ async listModels(apiKey) {
1474
+ const url = `${this.baseURL}/models?key=${apiKey}`;
1475
+
1476
+ const fetchOptions = {
1477
+ method: 'GET',
1478
+ headers: {
1479
+ 'Content-Type': 'application/json'
1480
+ }
1481
+ };
1482
+
1483
+ const response = await this.makeRequest(url, fetchOptions);
1484
+
1485
+ return response.models
1486
+ .filter(model => model.supportedGenerationMethods?.includes('generateContent'))
1487
+ .map(model => ({
1488
+ id: model.name.replace('models/', ''),
1489
+ displayName: model.displayName,
1490
+ description: model.description,
1491
+ inputTokenLimit: model.inputTokenLimit,
1492
+ outputTokenLimit: model.outputTokenLimit
1493
+ }));
1494
+ }
1495
+
1496
+ /**
1497
+ * Count tokens
1498
+ * @param {string} text - Text to count tokens for
1499
+ * @param {string} apiKey - API key
1500
+ * @param {string} model - Model name
1501
+ */
1502
+ async countTokens(text, apiKey, model = this.defaultModel) {
1503
+ const url = `${this.baseURL}/models/${model}:countTokens?key=${apiKey}`;
1504
+
1505
+ const requestBody = {
1506
+ contents: [{
1507
+ role: 'user',
1508
+ parts: [{ text }]
1509
+ }]
1510
+ };
1511
+
1512
+ const fetchOptions = {
1513
+ method: 'POST',
1514
+ headers: {
1515
+ 'Content-Type': 'application/json'
1516
+ },
1517
+ body: JSON.stringify(requestBody)
1518
+ };
1519
+
1520
+ const response = await this.makeRequest(url, fetchOptions);
1521
+
1522
+ return {
1523
+ totalTokens: response.totalTokens || 0
1524
+ };
1525
+ }
1526
+
1527
+ /**
1528
+ * Generate embeddings
1529
+ * @param {string} text - Text to embed
1530
+ * @param {string} apiKey - API key
1531
+ */
1532
+ async createEmbedding(text, apiKey) {
1533
+ const model = 'embedding-001';
1534
+ const url = `${this.baseURL}/models/${model}:embedContent?key=${apiKey}`;
1535
+
1536
+ const requestBody = {
1537
+ content: {
1538
+ parts: [{ text }]
1539
+ }
1540
+ };
1541
+
1542
+ const fetchOptions = {
1543
+ method: 'POST',
1544
+ headers: {
1545
+ 'Content-Type': 'application/json'
1546
+ },
1547
+ body: JSON.stringify(requestBody)
1548
+ };
1549
+
1550
+ const response = await this.makeRequest(url, fetchOptions);
1551
+
1552
+ return {
1553
+ embedding: response.embedding.values,
1554
+ model: model
1555
+ };
1556
+ }
1557
+ }
1558
+
1559
+ /**
1560
+ * AIKit - Universal AI API Client
1561
+ * @class
1562
+ */
1563
+
1564
+ class AIKit {
1565
+ /**
1566
+ * @param {Object} config - Configuration object
1567
+ * @param {string} config.provider - AI provider name ('openai', 'claude', 'gemini')
1568
+ * @param {string} config.apiKey - API key for the provider
1569
+ * @param {Object} [config.options] - Additional options
1570
+ */
1571
+ constructor(config) {
1572
+ this.validateConfig(config);
1573
+
1574
+ this.config = {
1575
+ provider: config.provider,
1576
+ apiKey: config.apiKey,
1577
+ options: config.options || {},
1578
+ autoFallback: config.autoFallback || false,
1579
+ enableCache: config.enableCache !== false,
1580
+ enableCostTracking: config.enableCostTracking !== false,
1581
+ maxRetries: config.maxRetries || 3,
1582
+ timeout: config.timeout || 30000
1583
+ };
1584
+
1585
+ // 멀티 프로바이더 설정
1586
+ this.providers = config.providers || [
1587
+ { name: config.provider, apiKey: config.apiKey, priority: 1 }
1588
+ ];
1589
+
1590
+ this.currentProviderIndex = 0;
1591
+
1592
+ // 모듈 초기화
1593
+ this.adapter = this.loadAdapter(this.config.provider);
1594
+ this.cache = this.config.enableCache ? new CacheManager() : null;
1595
+ this.costTracker = this.config.enableCostTracking ? new CostTracker() : null;
1596
+ this.validator = new ResponseValidator();
1597
+
1598
+ // 통계
1599
+ this.stats = {
1600
+ totalRequests: 0,
1601
+ successfulRequests: 0,
1602
+ failedRequests: 0,
1603
+ cachedResponses: 0
1604
+ };
1605
+ }
1606
+
1607
+ /**
1608
+ * 설정 유효성 검사
1609
+ * @private
1610
+ */
1611
+ validateConfig(config) {
1612
+ if (!config) {
1613
+ throw new Error('AIKit: Configuration is required');
1614
+ }
1615
+
1616
+ if (config.providers) {
1617
+ // 멀티 프로바이더 모드
1618
+ if (!Array.isArray(config.providers) || config.providers.length === 0) {
1619
+ throw new Error('AIKit: providers must be a non-empty array');
1620
+ }
1621
+
1622
+ config.providers.forEach((p, i) => {
1623
+ if (!p.name || !p.apiKey) {
1624
+ throw new Error(`AIKit: Provider at index ${i} must have 'name' and 'apiKey'`);
1625
+ }
1626
+ });
1627
+ } else {
1628
+ // 싱글 프로바이더 모드
1629
+ if (!config.provider) {
1630
+ throw new Error('AIKit: provider is required');
1631
+ }
1632
+
1633
+ if (!config.apiKey) {
1634
+ throw new Error('AIKit: apiKey is required');
1635
+ }
1636
+ }
1637
+ }
1638
+
1639
+ /**
1640
+ * 프로바이더 어댑터 로드
1641
+ * @private
1642
+ */
1643
+ loadAdapter(provider) {
1644
+ const adapterMap = {
1645
+ 'openai': OpenAIAdapter,
1646
+ 'claude': ClaudeAdapter,
1647
+ 'gemini': GeminiAdapter
1648
+ };
1649
+
1650
+ const AdapterClass = adapterMap[provider.toLowerCase()];
1651
+ if (!AdapterClass) {
1652
+ throw new Error(`Unknown provider: ${provider}`);
1653
+ }
1654
+
1655
+ return new AdapterClass();
1656
+ }
1657
+
1658
+ /**
1659
+ * AI 채팅 요청
1660
+ * @param {string} message - User message
1661
+ * @param {Object} [options] - Request options
1662
+ * @returns {Promise<Object>} Response object
1663
+ */
1664
+ async chat(message, options = {}) {
1665
+ this.stats.totalRequests++;
1666
+
1667
+ // 캐시 체크
1668
+ if (this.cache && !options.skipCache) {
1669
+ const cacheKey = this.cache.generateKey(message, options);
1670
+ const cached = this.cache.get(cacheKey);
1671
+
1672
+ if (cached) {
1673
+ this.stats.cachedResponses++;
1674
+ return {
1675
+ ...cached,
1676
+ fromCache: true,
1677
+ timestamp: new Date().toISOString()
1678
+ };
1679
+ }
1680
+ }
1681
+
1682
+ // 검증 옵션 추출
1683
+ const validation = options.validate || {};
1684
+ delete options.validate;
1685
+
1686
+ // 재시도 로직
1687
+ let lastError;
1688
+ for (let attempt = 0; attempt < this.config.maxRetries; attempt++) {
1689
+ try {
1690
+ const response = await this.makeRequest(message, options);
1691
+
1692
+ // 응답 검증
1693
+ if (Object.keys(validation).length > 0) {
1694
+ const validationResult = this.validator.validate(response, validation);
1695
+ if (!validationResult.isValid) {
1696
+ throw new Error(`Validation failed: ${validationResult.errors.join(', ')}`);
1697
+ }
1698
+ }
1699
+
1700
+ // 캐시 저장
1701
+ if (this.cache && !options.skipCache) {
1702
+ const cacheKey = this.cache.generateKey(message, options);
1703
+ this.cache.set(cacheKey, response);
1704
+ }
1705
+
1706
+ // 비용 추적
1707
+ if (this.costTracker) {
1708
+ this.costTracker.track({
1709
+ provider: this.config.provider,
1710
+ tokens: response.usage || {},
1711
+ timestamp: new Date().toISOString()
1712
+ });
1713
+ }
1714
+
1715
+ this.stats.successfulRequests++;
1716
+
1717
+ return {
1718
+ ...response,
1719
+ fromCache: false,
1720
+ timestamp: new Date().toISOString()
1721
+ };
1722
+
1723
+ } catch (error) {
1724
+ lastError = error;
1725
+ console.warn(`AIKit: Attempt ${attempt + 1} failed:`, error.message);
1726
+
1727
+ // Auto fallback to next provider
1728
+ if (this.config.autoFallback && this.providers.length > 1) {
1729
+ const switched = await this.switchToNextProvider();
1730
+ if (!switched) break;
1731
+ } else {
1732
+ // Wait before retry
1733
+ if (attempt < this.config.maxRetries - 1) {
1734
+ await this.sleep(Math.pow(2, attempt) * 1000);
1735
+ }
1736
+ }
1737
+ }
1738
+ }
1739
+
1740
+ this.stats.failedRequests++;
1741
+ throw new Error(`AIKit: All attempts failed. Last error: ${lastError.message}`);
1742
+ }
1743
+
1744
+ /**
1745
+ * API 요청 실행
1746
+ * @private
1747
+ */
1748
+ async makeRequest(message, options) {
1749
+ const provider = this.providers[this.currentProviderIndex];
1750
+
1751
+ const requestData = {
1752
+ message,
1753
+ apiKey: provider.apiKey,
1754
+ options: {
1755
+ ...this.config.options,
1756
+ ...options
1757
+ }
1758
+ };
1759
+
1760
+ return await this.adapter.chat(requestData);
1761
+ }
1762
+
1763
+ /**
1764
+ * 다음 프로바이더로 전환
1765
+ * @private
1766
+ */
1767
+ async switchToNextProvider() {
1768
+ this.currentProviderIndex++;
1769
+
1770
+ if (this.currentProviderIndex >= this.providers.length) {
1771
+ this.currentProviderIndex = 0;
1772
+ return false; // 모든 프로바이더 시도 완료
1773
+ }
1774
+
1775
+ const nextProvider = this.providers[this.currentProviderIndex];
1776
+ console.log(`AIKit: Switching to provider '${nextProvider.name}'`);
1777
+
1778
+ this.adapter = this.loadAdapter(nextProvider.name);
1779
+ this.config.provider = nextProvider.name;
1780
+
1781
+ return true;
1782
+ }
1783
+
1784
+ /**
1785
+ * Sleep utility
1786
+ * @private
1787
+ */
1788
+ sleep(ms) {
1789
+ return new Promise(resolve => setTimeout(resolve, ms));
1790
+ }
1791
+
1792
+ /**
1793
+ * 비용 리포트 가져오기
1794
+ */
1795
+ getCostReport() {
1796
+ if (!this.costTracker) {
1797
+ return { error: 'Cost tracking is disabled' };
1798
+ }
1799
+ return this.costTracker.getReport();
1800
+ }
1801
+
1802
+ /**
1803
+ * 통계 가져오기
1804
+ */
1805
+ getStats() {
1806
+ return { ...this.stats };
1807
+ }
1808
+
1809
+ /**
1810
+ * 캐시 초기화
1811
+ */
1812
+ clearCache() {
1813
+ if (this.cache) {
1814
+ this.cache.clear();
1815
+ return true;
1816
+ }
1817
+ return false;
1818
+ }
1819
+
1820
+ /**
1821
+ * 설정 업데이트
1822
+ */
1823
+ updateConfig(newConfig) {
1824
+ Object.assign(this.config, newConfig);
1825
+
1826
+ if (newConfig.provider && newConfig.provider !== this.config.provider) {
1827
+ this.adapter = this.loadAdapter(newConfig.provider);
1828
+ }
1829
+ }
1830
+ }
1831
+
1832
+ // Import all modules
1833
+
1834
+ // Attach utilities to AIKit for easy access
1835
+ AIKit.CacheManager = CacheManager;
1836
+ AIKit.CostTracker = CostTracker;
1837
+ AIKit.ResponseValidator = ResponseValidator;
1838
+ AIKit.BaseAdapter = BaseAdapter;
1839
+ AIKit.OpenAIAdapter = OpenAIAdapter;
1840
+ AIKit.ClaudeAdapter = ClaudeAdapter;
1841
+ AIKit.GeminiAdapter = GeminiAdapter;
1842
+
1843
+ // Attach to global for browser usage
1844
+ if (typeof window !== 'undefined') {
1845
+ window.AIKit = AIKit;
1846
+ }
1847
+
1848
+ return AIKit;
1849
+
1850
+ }));
1851
+ //# sourceMappingURL=aikit.umd.js.map