@decido/kernel-bridge 1.0.0 → 4.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 +31 -0
- package/dist/index.js +2593 -0
- package/dist/index.mjs +2518 -0
- package/package.json +13 -7
- package/.turbo/turbo-build.log +0 -13
- package/.turbo/turbo-lint.log +0 -30
- package/src/ai/components/PeerNetworkPanel.tsx +0 -219
- package/src/ai/components/TokenWalletPanel.tsx +0 -172
- package/src/ai/hooks/usePeerMesh.ts +0 -79
- package/src/ai/hooks/useTokenWallet.ts +0 -35
- package/src/ai/index.ts +0 -96
- package/src/ai/services/EmbeddingService.ts +0 -119
- package/src/ai/services/InferenceRouter.ts +0 -347
- package/src/ai/services/LocalAgentResponder.ts +0 -199
- package/src/ai/services/MLXBridge.ts +0 -278
- package/src/ai/services/OllamaService.ts +0 -326
- package/src/ai/services/PeerMesh.ts +0 -373
- package/src/ai/services/TokenWallet.ts +0 -237
- package/src/ai/services/providers/AnthropicProvider.ts +0 -229
- package/src/ai/services/providers/GeminiProvider.ts +0 -121
- package/src/ai/services/providers/LLMProvider.ts +0 -72
- package/src/ai/services/providers/OllamaProvider.ts +0 -84
- package/src/ai/services/providers/OpenAIProvider.ts +0 -178
- package/src/crypto.ts +0 -54
- package/src/index.ts +0 -4
- package/src/kernel.ts +0 -376
- package/src/rehydration.ts +0 -52
- package/tsconfig.json +0 -18
|
@@ -1,237 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* TokenWallet — Usage tracking & cost estimation for LLM providers
|
|
3
|
-
*
|
|
4
|
-
* Records every inference call and calculates estimated costs
|
|
5
|
-
* using a per-model pricing table. Persists via localStorage
|
|
6
|
-
* (with Tauri fs fallback).
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
// ─── Pricing Table (USD per 1M tokens) ─────────────────────
|
|
10
|
-
|
|
11
|
-
interface ModelPricing {
|
|
12
|
-
input: number; // per 1M input tokens
|
|
13
|
-
output: number; // per 1M output tokens
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
const PRICING: Record<string, ModelPricing> = {
|
|
17
|
-
// Ollama / local — free
|
|
18
|
-
'qwen2:latest': { input: 0, output: 0 },
|
|
19
|
-
'llama3:latest': { input: 0, output: 0 },
|
|
20
|
-
'mistral:latest': { input: 0, output: 0 },
|
|
21
|
-
'codellama:latest': { input: 0, output: 0 },
|
|
22
|
-
|
|
23
|
-
// Gemini
|
|
24
|
-
'gemini-flash-lite-latest': { input: 0.10, output: 0.40 },
|
|
25
|
-
'gemini-flash-lite-latest-lite': { input: 0.075, output: 0.30 },
|
|
26
|
-
'gemini-1.5-pro': { input: 1.25, output: 5.00 },
|
|
27
|
-
'gemini-flash-latest': { input: 0.075, output: 0.30 },
|
|
28
|
-
|
|
29
|
-
// Anthropic
|
|
30
|
-
'claude-sonnet-4-20250514': { input: 3.00, output: 15.00 },
|
|
31
|
-
'claude-3-5-sonnet-20241022': { input: 3.00, output: 15.00 },
|
|
32
|
-
'claude-3-5-haiku-20241022': { input: 0.80, output: 4.00 },
|
|
33
|
-
'claude-3-opus-20240229': { input: 15.00, output: 75.00 },
|
|
34
|
-
|
|
35
|
-
// OpenAI
|
|
36
|
-
'gpt-4o': { input: 2.50, output: 10.00 },
|
|
37
|
-
'gpt-4o-mini': { input: 0.15, output: 0.60 },
|
|
38
|
-
'gpt-4-turbo': { input: 10.00, output: 30.00 },
|
|
39
|
-
'gpt-4': { input: 30.00, output: 60.00 },
|
|
40
|
-
'gpt-3.5-turbo': { input: 0.50, output: 1.50 },
|
|
41
|
-
'o3-mini': { input: 1.10, output: 4.40 },
|
|
42
|
-
};
|
|
43
|
-
|
|
44
|
-
// Default pricing for unknown models (assume cloud provider)
|
|
45
|
-
const DEFAULT_PRICING: ModelPricing = { input: 1.00, output: 3.00 };
|
|
46
|
-
|
|
47
|
-
// ─── Types ──────────────────────────────────────────────────
|
|
48
|
-
|
|
49
|
-
export interface TokenUsageEntry {
|
|
50
|
-
provider: string;
|
|
51
|
-
model: string;
|
|
52
|
-
inputTokens: number;
|
|
53
|
-
outputTokens: number;
|
|
54
|
-
totalTokens: number;
|
|
55
|
-
estimatedCostUsd: number;
|
|
56
|
-
timestamp: number;
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
export interface ProviderSummary {
|
|
60
|
-
tokens: number;
|
|
61
|
-
cost: number;
|
|
62
|
-
calls: number;
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
export interface WalletSummary {
|
|
66
|
-
totalTokens: number;
|
|
67
|
-
totalCostUsd: number;
|
|
68
|
-
totalCalls: number;
|
|
69
|
-
byProvider: Record<string, ProviderSummary>;
|
|
70
|
-
sessionStart: number;
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
type WalletListener = (summary: WalletSummary) => void;
|
|
74
|
-
|
|
75
|
-
// ─── Storage Key ────────────────────────────────────────────
|
|
76
|
-
|
|
77
|
-
const STORAGE_KEY = 'decido-token-wallet';
|
|
78
|
-
const MAX_HISTORY = 500; // keep last 500 entries
|
|
79
|
-
|
|
80
|
-
// ─── TokenWallet Class ──────────────────────────────────────
|
|
81
|
-
|
|
82
|
-
class TokenWalletImpl {
|
|
83
|
-
private history: TokenUsageEntry[] = [];
|
|
84
|
-
private sessionStart = Date.now();
|
|
85
|
-
private listeners = new Set<WalletListener>();
|
|
86
|
-
|
|
87
|
-
constructor() {
|
|
88
|
-
this._loadFromStorage();
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
// ── Record Usage ────────────────────────────────────────
|
|
92
|
-
|
|
93
|
-
record(entry: {
|
|
94
|
-
provider: string;
|
|
95
|
-
model: string;
|
|
96
|
-
tokensUsed?: number;
|
|
97
|
-
inputTokens?: number;
|
|
98
|
-
outputTokens?: number;
|
|
99
|
-
latencyMs?: number;
|
|
100
|
-
}): void {
|
|
101
|
-
// Estimate input/output split if not provided
|
|
102
|
-
const totalTokens = entry.tokensUsed ?? (entry.inputTokens ?? 0) + (entry.outputTokens ?? 0);
|
|
103
|
-
const inputTokens = entry.inputTokens ?? Math.round(totalTokens * 0.4);
|
|
104
|
-
const outputTokens = entry.outputTokens ?? (totalTokens - inputTokens);
|
|
105
|
-
|
|
106
|
-
const pricing = this._getPricing(entry.model, entry.provider);
|
|
107
|
-
const estimatedCostUsd =
|
|
108
|
-
(inputTokens / 1_000_000) * pricing.input +
|
|
109
|
-
(outputTokens / 1_000_000) * pricing.output;
|
|
110
|
-
|
|
111
|
-
const usageEntry: TokenUsageEntry = {
|
|
112
|
-
provider: entry.provider,
|
|
113
|
-
model: entry.model,
|
|
114
|
-
inputTokens,
|
|
115
|
-
outputTokens,
|
|
116
|
-
totalTokens,
|
|
117
|
-
estimatedCostUsd,
|
|
118
|
-
timestamp: Date.now(),
|
|
119
|
-
};
|
|
120
|
-
|
|
121
|
-
this.history.push(usageEntry);
|
|
122
|
-
|
|
123
|
-
// Trim history if too large
|
|
124
|
-
if (this.history.length > MAX_HISTORY) {
|
|
125
|
-
this.history = this.history.slice(-MAX_HISTORY);
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
this._saveToStorage();
|
|
129
|
-
this._emit();
|
|
130
|
-
|
|
131
|
-
console.log(
|
|
132
|
-
`💰 [Wallet] ${entry.provider}/${entry.model}: ${totalTokens} tokens ≈ $${estimatedCostUsd.toFixed(6)}`
|
|
133
|
-
);
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
// ── Summary ─────────────────────────────────────────────
|
|
137
|
-
|
|
138
|
-
getSummary(): WalletSummary {
|
|
139
|
-
const byProvider: Record<string, ProviderSummary> = {};
|
|
140
|
-
let totalTokens = 0;
|
|
141
|
-
let totalCostUsd = 0;
|
|
142
|
-
|
|
143
|
-
for (const entry of this.history) {
|
|
144
|
-
totalTokens += entry.totalTokens;
|
|
145
|
-
totalCostUsd += entry.estimatedCostUsd;
|
|
146
|
-
|
|
147
|
-
if (!byProvider[entry.provider]) {
|
|
148
|
-
byProvider[entry.provider] = { tokens: 0, cost: 0, calls: 0 };
|
|
149
|
-
}
|
|
150
|
-
byProvider[entry.provider].tokens += entry.totalTokens;
|
|
151
|
-
byProvider[entry.provider].cost += entry.estimatedCostUsd;
|
|
152
|
-
byProvider[entry.provider].calls += 1;
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
return {
|
|
156
|
-
totalTokens,
|
|
157
|
-
totalCostUsd,
|
|
158
|
-
totalCalls: this.history.length,
|
|
159
|
-
byProvider,
|
|
160
|
-
sessionStart: this.sessionStart,
|
|
161
|
-
};
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
getHistory(): TokenUsageEntry[] {
|
|
165
|
-
return [...this.history];
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
getRecentHistory(count: number): TokenUsageEntry[] {
|
|
169
|
-
return this.history.slice(-count);
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
// ── Lifecycle ───────────────────────────────────────────
|
|
173
|
-
|
|
174
|
-
clearHistory(): void {
|
|
175
|
-
this.history = [];
|
|
176
|
-
this.sessionStart = Date.now();
|
|
177
|
-
this._saveToStorage();
|
|
178
|
-
this._emit();
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
// ── Subscriptions ───────────────────────────────────────
|
|
182
|
-
|
|
183
|
-
subscribe(listener: WalletListener): () => void {
|
|
184
|
-
this.listeners.add(listener);
|
|
185
|
-
return () => { this.listeners.delete(listener); };
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
// ── Pricing Lookup ──────────────────────────────────────
|
|
189
|
-
|
|
190
|
-
private _getPricing(model: string, provider: string): ModelPricing {
|
|
191
|
-
// Exact match
|
|
192
|
-
if (PRICING[model]) return PRICING[model];
|
|
193
|
-
|
|
194
|
-
// Local providers are always free
|
|
195
|
-
if (provider === 'ollama' || provider === 'mlx') {
|
|
196
|
-
return { input: 0, output: 0 };
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
return DEFAULT_PRICING;
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
// ── Persistence ─────────────────────────────────────────
|
|
203
|
-
|
|
204
|
-
private _saveToStorage(): void {
|
|
205
|
-
try {
|
|
206
|
-
const data = JSON.stringify({
|
|
207
|
-
history: this.history,
|
|
208
|
-
sessionStart: this.sessionStart,
|
|
209
|
-
});
|
|
210
|
-
localStorage.setItem(STORAGE_KEY, data);
|
|
211
|
-
} catch { /* storage full or unavailable */ }
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
private _loadFromStorage(): void {
|
|
215
|
-
try {
|
|
216
|
-
const raw = localStorage.getItem(STORAGE_KEY);
|
|
217
|
-
if (raw) {
|
|
218
|
-
const data = JSON.parse(raw);
|
|
219
|
-
this.history = data.history || [];
|
|
220
|
-
this.sessionStart = data.sessionStart || Date.now();
|
|
221
|
-
}
|
|
222
|
-
} catch { /* corrupted data, start fresh */ }
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
// ── Emit ────────────────────────────────────────────────
|
|
226
|
-
|
|
227
|
-
private _emit(): void {
|
|
228
|
-
const summary = this.getSummary();
|
|
229
|
-
for (const listener of this.listeners) {
|
|
230
|
-
try { listener(summary); } catch { /* ignore listener errors */ }
|
|
231
|
-
}
|
|
232
|
-
}
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
// ─── Singleton Export ───────────────────────────────────────
|
|
236
|
-
|
|
237
|
-
export const tokenWallet = new TokenWalletImpl();
|
|
@@ -1,229 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* AnthropicProvider — Claude LLM provider
|
|
3
|
-
*
|
|
4
|
-
* Supports Claude 3.5 Sonnet, Claude 3 Opus/Haiku via Anthropic Messages API.
|
|
5
|
-
* Implements both streaming and non-streaming chat.
|
|
6
|
-
*/
|
|
7
|
-
|
|
8
|
-
import type { LLMProvider, ChatMessage, ChatOptions, ChatResult, ProviderStatus } from './LLMProvider';
|
|
9
|
-
|
|
10
|
-
// ─── Config ─────────────────────────────────────────────────
|
|
11
|
-
|
|
12
|
-
const ANTHROPIC_API_BASE = 'https://api.anthropic.com/v1';
|
|
13
|
-
const DEFAULT_MODEL = 'claude-sonnet-4-20250514';
|
|
14
|
-
const API_VERSION = '2023-06-01';
|
|
15
|
-
|
|
16
|
-
const AVAILABLE_MODELS = [
|
|
17
|
-
'claude-sonnet-4-20250514',
|
|
18
|
-
'claude-3-5-sonnet-20241022',
|
|
19
|
-
'claude-3-5-haiku-20241022',
|
|
20
|
-
'claude-3-opus-20240229',
|
|
21
|
-
];
|
|
22
|
-
|
|
23
|
-
// ─── Provider Implementation ────────────────────────────────
|
|
24
|
-
|
|
25
|
-
export class AnthropicProvider implements LLMProvider {
|
|
26
|
-
readonly id = 'anthropic';
|
|
27
|
-
readonly name = 'Anthropic Claude';
|
|
28
|
-
readonly requiresApiKey = true;
|
|
29
|
-
readonly defaultModel = DEFAULT_MODEL;
|
|
30
|
-
|
|
31
|
-
private apiKey: string | null = null;
|
|
32
|
-
|
|
33
|
-
setApiKey(key: string): void {
|
|
34
|
-
this.apiKey = key;
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
async listModels(): Promise<string[]> {
|
|
38
|
-
return AVAILABLE_MODELS;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
async checkStatus(): Promise<ProviderStatus> {
|
|
42
|
-
if (!this.apiKey) return 'unconfigured';
|
|
43
|
-
|
|
44
|
-
try {
|
|
45
|
-
// Quick health check — list models endpoint
|
|
46
|
-
const res = await fetch(`${ANTHROPIC_API_BASE}/messages`, {
|
|
47
|
-
method: 'POST',
|
|
48
|
-
headers: {
|
|
49
|
-
'x-api-key': this.apiKey,
|
|
50
|
-
'anthropic-version': API_VERSION,
|
|
51
|
-
'content-type': 'application/json',
|
|
52
|
-
},
|
|
53
|
-
body: JSON.stringify({
|
|
54
|
-
model: this.defaultModel,
|
|
55
|
-
max_tokens: 1,
|
|
56
|
-
messages: [{ role: 'user', content: 'ping' }],
|
|
57
|
-
}),
|
|
58
|
-
signal: AbortSignal.timeout(5000),
|
|
59
|
-
});
|
|
60
|
-
return res.ok || res.status === 429 ? 'available' : 'error';
|
|
61
|
-
} catch {
|
|
62
|
-
return 'unavailable';
|
|
63
|
-
}
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
async chat(messages: ChatMessage[], options?: ChatOptions): Promise<ChatResult> {
|
|
67
|
-
if (!this.apiKey) {
|
|
68
|
-
throw new Error('Anthropic API key not configured. Set it in Settings → Providers.');
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
const model = options?.model || this.defaultModel;
|
|
72
|
-
const start = Date.now();
|
|
73
|
-
|
|
74
|
-
// Separate system from conversation
|
|
75
|
-
const systemMsg = messages.find(m => m.role === 'system');
|
|
76
|
-
const conversationMsgs = messages
|
|
77
|
-
.filter(m => m.role !== 'system')
|
|
78
|
-
.map(m => ({
|
|
79
|
-
role: m.role as 'user' | 'assistant',
|
|
80
|
-
content: m.content,
|
|
81
|
-
}));
|
|
82
|
-
|
|
83
|
-
// Ensure alternating user/assistant messages (Anthropic requirement)
|
|
84
|
-
const sanitized = this._sanitizeMessages(conversationMsgs);
|
|
85
|
-
|
|
86
|
-
const body: Record<string, unknown> = {
|
|
87
|
-
model,
|
|
88
|
-
max_tokens: options?.maxTokens ?? 2048,
|
|
89
|
-
messages: sanitized,
|
|
90
|
-
};
|
|
91
|
-
|
|
92
|
-
if (options?.temperature !== undefined) {
|
|
93
|
-
body.temperature = options.temperature;
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
if (systemMsg) {
|
|
97
|
-
body.system = systemMsg.content;
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
const res = await fetch(`${ANTHROPIC_API_BASE}/messages`, {
|
|
101
|
-
method: 'POST',
|
|
102
|
-
headers: {
|
|
103
|
-
'x-api-key': this.apiKey,
|
|
104
|
-
'anthropic-version': API_VERSION,
|
|
105
|
-
'content-type': 'application/json',
|
|
106
|
-
},
|
|
107
|
-
body: JSON.stringify(body),
|
|
108
|
-
signal: options?.signal ?? AbortSignal.timeout(60000),
|
|
109
|
-
});
|
|
110
|
-
|
|
111
|
-
if (!res.ok) {
|
|
112
|
-
const errorBody = await res.text();
|
|
113
|
-
throw new Error(`Anthropic API error ${res.status}: ${errorBody}`);
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
const data = await res.json();
|
|
117
|
-
const latencyMs = Date.now() - start;
|
|
118
|
-
|
|
119
|
-
// Extract text from content blocks
|
|
120
|
-
const text = (data.content || [])
|
|
121
|
-
.filter((block: { type: string }) => block.type === 'text')
|
|
122
|
-
.map((block: { text: string }) => block.text)
|
|
123
|
-
.join('') || '(sin respuesta)';
|
|
124
|
-
|
|
125
|
-
const tokensUsed =
|
|
126
|
-
(data.usage?.input_tokens || 0) + (data.usage?.output_tokens || 0);
|
|
127
|
-
|
|
128
|
-
return { text, model, latencyMs, tokensUsed };
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
/** Stream chat completion — yields text chunks */
|
|
132
|
-
async *chatStream(
|
|
133
|
-
messages: ChatMessage[],
|
|
134
|
-
options?: ChatOptions,
|
|
135
|
-
): AsyncGenerator<string, void, unknown> {
|
|
136
|
-
if (!this.apiKey) {
|
|
137
|
-
throw new Error('Anthropic API key not configured.');
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
const model = options?.model || this.defaultModel;
|
|
141
|
-
const systemMsg = messages.find(m => m.role === 'system');
|
|
142
|
-
const conversationMsgs = messages
|
|
143
|
-
.filter(m => m.role !== 'system')
|
|
144
|
-
.map(m => ({ role: m.role as 'user' | 'assistant', content: m.content }));
|
|
145
|
-
|
|
146
|
-
const sanitized = this._sanitizeMessages(conversationMsgs);
|
|
147
|
-
|
|
148
|
-
const body: Record<string, unknown> = {
|
|
149
|
-
model,
|
|
150
|
-
max_tokens: options?.maxTokens ?? 2048,
|
|
151
|
-
messages: sanitized,
|
|
152
|
-
stream: true,
|
|
153
|
-
};
|
|
154
|
-
|
|
155
|
-
if (options?.temperature !== undefined) body.temperature = options.temperature;
|
|
156
|
-
if (systemMsg) body.system = systemMsg.content;
|
|
157
|
-
|
|
158
|
-
const res = await fetch(`${ANTHROPIC_API_BASE}/messages`, {
|
|
159
|
-
method: 'POST',
|
|
160
|
-
headers: {
|
|
161
|
-
'x-api-key': this.apiKey,
|
|
162
|
-
'anthropic-version': API_VERSION,
|
|
163
|
-
'content-type': 'application/json',
|
|
164
|
-
},
|
|
165
|
-
body: JSON.stringify(body),
|
|
166
|
-
signal: options?.signal ?? AbortSignal.timeout(120000),
|
|
167
|
-
});
|
|
168
|
-
|
|
169
|
-
if (!res.ok) {
|
|
170
|
-
const errorBody = await res.text();
|
|
171
|
-
throw new Error(`Anthropic stream error ${res.status}: ${errorBody}`);
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
const reader = res.body?.getReader();
|
|
175
|
-
if (!reader) return;
|
|
176
|
-
|
|
177
|
-
const decoder = new TextDecoder();
|
|
178
|
-
let buffer = '';
|
|
179
|
-
|
|
180
|
-
while (true) {
|
|
181
|
-
const { done, value } = await reader.read();
|
|
182
|
-
if (done) break;
|
|
183
|
-
|
|
184
|
-
buffer += decoder.decode(value, { stream: true });
|
|
185
|
-
const lines = buffer.split('\n');
|
|
186
|
-
buffer = lines.pop() || '';
|
|
187
|
-
|
|
188
|
-
for (const line of lines) {
|
|
189
|
-
if (!line.startsWith('data: ')) continue;
|
|
190
|
-
const data = line.slice(6).trim();
|
|
191
|
-
if (data === '[DONE]') return;
|
|
192
|
-
|
|
193
|
-
try {
|
|
194
|
-
const event = JSON.parse(data);
|
|
195
|
-
if (event.type === 'content_block_delta' && event.delta?.text) {
|
|
196
|
-
yield event.delta.text;
|
|
197
|
-
}
|
|
198
|
-
} catch { /* skip malformed lines */ }
|
|
199
|
-
}
|
|
200
|
-
}
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
/** Ensure alternating user/assistant (Anthropic requires this) */
|
|
204
|
-
private _sanitizeMessages(
|
|
205
|
-
msgs: Array<{ role: string; content: string }>,
|
|
206
|
-
): Array<{ role: string; content: string }> {
|
|
207
|
-
if (msgs.length === 0) return [{ role: 'user', content: '...' }];
|
|
208
|
-
|
|
209
|
-
const result: Array<{ role: string; content: string }> = [];
|
|
210
|
-
let lastRole = '';
|
|
211
|
-
|
|
212
|
-
for (const msg of msgs) {
|
|
213
|
-
if (msg.role === lastRole) {
|
|
214
|
-
// Merge consecutive same-role messages
|
|
215
|
-
result[result.length - 1].content += '\n' + msg.content;
|
|
216
|
-
} else {
|
|
217
|
-
result.push({ ...msg });
|
|
218
|
-
lastRole = msg.role;
|
|
219
|
-
}
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
// Must start with 'user'
|
|
223
|
-
if (result[0]?.role !== 'user') {
|
|
224
|
-
result.unshift({ role: 'user', content: '...' });
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
return result;
|
|
228
|
-
}
|
|
229
|
-
}
|
|
@@ -1,121 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* GeminiProvider — Google Gemini LLM provider
|
|
3
|
-
*
|
|
4
|
-
* Uses the Gemini REST API directly (no SDK dependency).
|
|
5
|
-
* Supports gemini-flash-lite-latest, gemini-1.5-pro, etc.
|
|
6
|
-
*/
|
|
7
|
-
|
|
8
|
-
import type { LLMProvider, ChatMessage, ChatOptions, ChatResult, ProviderStatus } from './LLMProvider';
|
|
9
|
-
|
|
10
|
-
// ─── Config ─────────────────────────────────────────────────
|
|
11
|
-
|
|
12
|
-
const GEMINI_API_BASE = 'https://generativelanguage.googleapis.com/v1beta';
|
|
13
|
-
const DEFAULT_MODEL = 'gemini-flash-lite-latest';
|
|
14
|
-
|
|
15
|
-
const AVAILABLE_MODELS = [
|
|
16
|
-
'gemini-flash-lite-latest',
|
|
17
|
-
'gemini-flash-lite-latest-lite',
|
|
18
|
-
'gemini-1.5-pro',
|
|
19
|
-
'gemini-flash-latest',
|
|
20
|
-
];
|
|
21
|
-
|
|
22
|
-
// ─── Provider Implementation ────────────────────────────────
|
|
23
|
-
|
|
24
|
-
export class GeminiProvider implements LLMProvider {
|
|
25
|
-
readonly id = 'gemini';
|
|
26
|
-
readonly name = 'Google Gemini';
|
|
27
|
-
readonly requiresApiKey = true;
|
|
28
|
-
readonly defaultModel = DEFAULT_MODEL;
|
|
29
|
-
|
|
30
|
-
private apiKey: string | null = null;
|
|
31
|
-
|
|
32
|
-
setApiKey(key: string): void {
|
|
33
|
-
this.apiKey = key;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
async listModels(): Promise<string[]> {
|
|
37
|
-
return AVAILABLE_MODELS;
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
async checkStatus(): Promise<ProviderStatus> {
|
|
41
|
-
if (!this.apiKey) return 'unconfigured';
|
|
42
|
-
|
|
43
|
-
try {
|
|
44
|
-
// Quick validation — list models endpoint
|
|
45
|
-
const res = await fetch(
|
|
46
|
-
`${GEMINI_API_BASE}/models?key=${this.apiKey}`,
|
|
47
|
-
{ signal: AbortSignal.timeout(5000) }
|
|
48
|
-
);
|
|
49
|
-
return res.ok ? 'available' : 'error';
|
|
50
|
-
} catch {
|
|
51
|
-
return 'unavailable';
|
|
52
|
-
}
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
async chat(messages: ChatMessage[], options?: ChatOptions): Promise<ChatResult> {
|
|
56
|
-
if (!this.apiKey) {
|
|
57
|
-
throw new Error('Gemini API key not configured. Set it in Settings → Providers.');
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
const model = options?.model || this.defaultModel;
|
|
61
|
-
const start = Date.now();
|
|
62
|
-
|
|
63
|
-
// Separate system instruction from conversation
|
|
64
|
-
const systemMsg = messages.find(m => m.role === 'system');
|
|
65
|
-
const conversationMsgs = messages.filter(m => m.role !== 'system');
|
|
66
|
-
|
|
67
|
-
// Convert to Gemini format
|
|
68
|
-
const contents = conversationMsgs.map(m => ({
|
|
69
|
-
role: m.role === 'assistant' ? 'model' : 'user',
|
|
70
|
-
parts: [{ text: m.content }],
|
|
71
|
-
}));
|
|
72
|
-
|
|
73
|
-
const body: Record<string, unknown> = {
|
|
74
|
-
contents,
|
|
75
|
-
generationConfig: {
|
|
76
|
-
temperature: options?.temperature ?? 0.7,
|
|
77
|
-
maxOutputTokens: options?.maxTokens ?? 2048,
|
|
78
|
-
},
|
|
79
|
-
};
|
|
80
|
-
|
|
81
|
-
if (systemMsg) {
|
|
82
|
-
body.systemInstruction = {
|
|
83
|
-
parts: [{ text: systemMsg.content }],
|
|
84
|
-
};
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
const url = `${GEMINI_API_BASE}/models/${model}:generateContent?key=${this.apiKey}`;
|
|
88
|
-
|
|
89
|
-
const res = await fetch(url, {
|
|
90
|
-
method: 'POST',
|
|
91
|
-
headers: { 'Content-Type': 'application/json' },
|
|
92
|
-
body: JSON.stringify(body),
|
|
93
|
-
signal: options?.signal ?? AbortSignal.timeout(60000),
|
|
94
|
-
});
|
|
95
|
-
|
|
96
|
-
if (!res.ok) {
|
|
97
|
-
const errorBody = await res.text();
|
|
98
|
-
throw new Error(`Gemini API error ${res.status}: ${errorBody}`);
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
const data = await res.json();
|
|
102
|
-
const latencyMs = Date.now() - start;
|
|
103
|
-
|
|
104
|
-
// Extract text from response
|
|
105
|
-
const candidate = data.candidates?.[0];
|
|
106
|
-
const text = candidate?.content?.parts
|
|
107
|
-
?.map((p: { text?: string }) => p.text || '')
|
|
108
|
-
.join('') || '(sin respuesta)';
|
|
109
|
-
|
|
110
|
-
const tokensUsed =
|
|
111
|
-
(data.usageMetadata?.promptTokenCount || 0) +
|
|
112
|
-
(data.usageMetadata?.candidatesTokenCount || 0);
|
|
113
|
-
|
|
114
|
-
return {
|
|
115
|
-
text,
|
|
116
|
-
model,
|
|
117
|
-
latencyMs,
|
|
118
|
-
tokensUsed,
|
|
119
|
-
};
|
|
120
|
-
}
|
|
121
|
-
}
|
|
@@ -1,72 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* LLMProvider — Universal interface for LLM backends
|
|
3
|
-
*
|
|
4
|
-
* All providers (Ollama, Gemini, OpenAI, etc.) implement this
|
|
5
|
-
* interface so the InferenceRouter can swap them transparently.
|
|
6
|
-
*/
|
|
7
|
-
|
|
8
|
-
// ─── Types ──────────────────────────────────────────────────
|
|
9
|
-
|
|
10
|
-
export interface ChatMessage {
|
|
11
|
-
role: 'system' | 'user' | 'assistant';
|
|
12
|
-
content: string;
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
export interface ChatOptions {
|
|
16
|
-
model?: string;
|
|
17
|
-
temperature?: number;
|
|
18
|
-
maxTokens?: number;
|
|
19
|
-
/** AbortSignal for cancellation */
|
|
20
|
-
signal?: AbortSignal;
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
export interface ChatResult {
|
|
24
|
-
text: string;
|
|
25
|
-
model: string;
|
|
26
|
-
latencyMs: number;
|
|
27
|
-
tokensUsed?: number;
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
export type ProviderStatus = 'available' | 'unavailable' | 'error' | 'unconfigured';
|
|
31
|
-
|
|
32
|
-
// ─── Provider Interface ─────────────────────────────────────
|
|
33
|
-
|
|
34
|
-
export interface LLMProvider {
|
|
35
|
-
/** Unique identifier */
|
|
36
|
-
readonly id: string;
|
|
37
|
-
/** Display name */
|
|
38
|
-
readonly name: string;
|
|
39
|
-
/** Whether this provider needs an API key */
|
|
40
|
-
readonly requiresApiKey: boolean;
|
|
41
|
-
/** Default model for this provider */
|
|
42
|
-
readonly defaultModel: string;
|
|
43
|
-
/** Available models */
|
|
44
|
-
listModels(): Promise<string[]>;
|
|
45
|
-
/** Health check */
|
|
46
|
-
checkStatus(): Promise<ProviderStatus>;
|
|
47
|
-
/** Send a chat completion request */
|
|
48
|
-
chat(messages: ChatMessage[], options?: ChatOptions): Promise<ChatResult>;
|
|
49
|
-
/** Set API key if required */
|
|
50
|
-
setApiKey?(key: string): void;
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
// ─── Provider Registry ──────────────────────────────────────
|
|
54
|
-
|
|
55
|
-
const providers = new Map<string, LLMProvider>();
|
|
56
|
-
|
|
57
|
-
export function registerProvider(provider: LLMProvider): void {
|
|
58
|
-
providers.set(provider.id, provider);
|
|
59
|
-
console.log(`🔌 [LLM] Provider registered: ${provider.name} (${provider.id})`);
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
export function getProvider(id: string): LLMProvider | undefined {
|
|
63
|
-
return providers.get(id);
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
export function getAllProviders(): LLMProvider[] {
|
|
67
|
-
return Array.from(providers.values());
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
export function getAvailableProviderIds(): string[] {
|
|
71
|
-
return Array.from(providers.keys());
|
|
72
|
-
}
|