zerochat 7.3.0__py3-none-any.whl
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.
- zerochat-7.3.0.dist-info/METADATA +70 -0
- zerochat-7.3.0.dist-info/RECORD +130 -0
- zerochat-7.3.0.dist-info/WHEEL +5 -0
- zerochat-7.3.0.dist-info/entry_points.txt +2 -0
- zerochat-7.3.0.dist-info/licenses/LICENSE +34 -0
- zerochat-7.3.0.dist-info/top_level.txt +2 -0
- zerochat.py +2004 -0
- zerochat_runtime/__init__.py +1 -0
- zerochat_runtime/assets/css/base.css +199 -0
- zerochat_runtime/assets/css/components/composer.css +1258 -0
- zerochat_runtime/assets/css/components/debug.css +777 -0
- zerochat_runtime/assets/css/components/header.css +97 -0
- zerochat_runtime/assets/css/components/icons.css +39 -0
- zerochat_runtime/assets/css/components/markdown.css +685 -0
- zerochat_runtime/assets/css/components/messages.css +473 -0
- zerochat_runtime/assets/css/components/modals.css +1319 -0
- zerochat_runtime/assets/css/components/sidebar.css +622 -0
- zerochat_runtime/assets/css/components/tools.css +1223 -0
- zerochat_runtime/assets/css/layout.css +171 -0
- zerochat_runtime/assets/css/print.css +120 -0
- zerochat_runtime/assets/css/styles.css +18 -0
- zerochat_runtime/assets/css/theme-overrides.css +555 -0
- zerochat_runtime/assets/css/tokens.css +165 -0
- zerochat_runtime/assets/help/architecture.html +247 -0
- zerochat_runtime/assets/help/composer.html +134 -0
- zerochat_runtime/assets/help/conversations.html +105 -0
- zerochat_runtime/assets/help/debug.html +108 -0
- zerochat_runtime/assets/help/en/architecture.html +247 -0
- zerochat_runtime/assets/help/en/composer.html +134 -0
- zerochat_runtime/assets/help/en/conversations.html +104 -0
- zerochat_runtime/assets/help/en/debug.html +105 -0
- zerochat_runtime/assets/help/en/gemini-free.html +39 -0
- zerochat_runtime/assets/help/en/index.html +191 -0
- zerochat_runtime/assets/help/en/learning.html +71 -0
- zerochat_runtime/assets/help/en/mcp.html +143 -0
- zerochat_runtime/assets/help/en/openrouter-free.html +39 -0
- zerochat_runtime/assets/help/en/profiles.html +173 -0
- zerochat_runtime/assets/help/en/rag.html +130 -0
- zerochat_runtime/assets/help/en/reasoning-telemetry.html +108 -0
- zerochat_runtime/assets/help/en/tools-agent.html +133 -0
- zerochat_runtime/assets/help/en/webllm.html +355 -0
- zerochat_runtime/assets/help/gemini-free.html +39 -0
- zerochat_runtime/assets/help/help.css +604 -0
- zerochat_runtime/assets/help/help.js +51 -0
- zerochat_runtime/assets/help/index.html +194 -0
- zerochat_runtime/assets/help/learning.html +71 -0
- zerochat_runtime/assets/help/mcp.html +143 -0
- zerochat_runtime/assets/help/openrouter-free.html +39 -0
- zerochat_runtime/assets/help/profiles.html +174 -0
- zerochat_runtime/assets/help/rag.html +130 -0
- zerochat_runtime/assets/help/reasoning-telemetry.html +111 -0
- zerochat_runtime/assets/help/tools-agent.html +133 -0
- zerochat_runtime/assets/help/webllm.html +359 -0
- zerochat_runtime/assets/js/agent-core.js +1553 -0
- zerochat_runtime/assets/js/api.js +844 -0
- zerochat_runtime/assets/js/app.js +2558 -0
- zerochat_runtime/assets/js/attachments.js +220 -0
- zerochat_runtime/assets/js/charts.js +338 -0
- zerochat_runtime/assets/js/chat-engine.js +566 -0
- zerochat_runtime/assets/js/config-store.js +207 -0
- zerochat_runtime/assets/js/context-manager.js +584 -0
- zerochat_runtime/assets/js/conversation-service.js +484 -0
- zerochat_runtime/assets/js/cookies.js +688 -0
- zerochat_runtime/assets/js/data-reset-service.js +136 -0
- zerochat_runtime/assets/js/debug.js +508 -0
- zerochat_runtime/assets/js/defaults.js +16 -0
- zerochat_runtime/assets/js/export.js +179 -0
- zerochat_runtime/assets/js/file-parser.js +2088 -0
- zerochat_runtime/assets/js/generation-controller.js +417 -0
- zerochat_runtime/assets/js/i18n.js +1483 -0
- zerochat_runtime/assets/js/icons.js +156 -0
- zerochat_runtime/assets/js/ingestionEngine.js +436 -0
- zerochat_runtime/assets/js/markdown.js +588 -0
- zerochat_runtime/assets/js/mcp.js +1271 -0
- zerochat_runtime/assets/js/message-turns.js +62 -0
- zerochat_runtime/assets/js/profile-backup.js +118 -0
- zerochat_runtime/assets/js/profile-export-bundle.js +481 -0
- zerochat_runtime/assets/js/profile-repository.js +213 -0
- zerochat_runtime/assets/js/providers-webllm.js +486 -0
- zerochat_runtime/assets/js/providers.js +1560 -0
- zerochat_runtime/assets/js/rag-index.js +295 -0
- zerochat_runtime/assets/js/rag-service.js +530 -0
- zerochat_runtime/assets/js/rag-ui.js +997 -0
- zerochat_runtime/assets/js/ragStorage.js +676 -0
- zerochat_runtime/assets/js/sandbox.js +449 -0
- zerochat_runtime/assets/js/state.js +704 -0
- zerochat_runtime/assets/js/storage-db.js +144 -0
- zerochat_runtime/assets/js/tool-cards.js +330 -0
- zerochat_runtime/assets/js/tool-security.js +653 -0
- zerochat_runtime/assets/js/tools/README.md +50 -0
- zerochat_runtime/assets/js/tools/builtin/agent-checkpoint.tool.js +212 -0
- zerochat_runtime/assets/js/tools/builtin/download-pdf.tool.js +111 -0
- zerochat_runtime/assets/js/tools/builtin/execute-javascript.tool.js +219 -0
- zerochat_runtime/assets/js/tools/builtin/fetch-web-page.tool.js +112 -0
- zerochat_runtime/assets/js/tools/builtin/list-documents.tool.js +117 -0
- zerochat_runtime/assets/js/tools/builtin/read-knowledge-chunk.tool.js +129 -0
- zerochat_runtime/assets/js/tools/builtin/read-knowledge-image.tool.js +95 -0
- zerochat_runtime/assets/js/tools/builtin/render-chart.tool.js +90 -0
- zerochat_runtime/assets/js/tools/builtin/search-knowledge-base.tool.js +124 -0
- zerochat_runtime/assets/js/tools/builtin/search-web.tool.js +125 -0
- zerochat_runtime/assets/js/tools/tool-manifest.js +53 -0
- zerochat_runtime/assets/js/tools/tool-runtime.js +77 -0
- zerochat_runtime/assets/js/ui-composer.js +319 -0
- zerochat_runtime/assets/js/ui-conversation.js +655 -0
- zerochat_runtime/assets/js/ui-dialogs.js +132 -0
- zerochat_runtime/assets/js/ui-generation-status.js +119 -0
- zerochat_runtime/assets/js/ui-inspector.js +804 -0
- zerochat_runtime/assets/js/ui-mcp.js +604 -0
- zerochat_runtime/assets/js/ui-profiles.js +824 -0
- zerochat_runtime/assets/js/ui-reasoning.js +146 -0
- zerochat_runtime/assets/js/ui-settings.js +825 -0
- zerochat_runtime/assets/js/ui-shell.js +215 -0
- zerochat_runtime/assets/js/ui-sidebar.js +429 -0
- zerochat_runtime/assets/js/ui-telemetry.js +404 -0
- zerochat_runtime/assets/js/ui-transfer.js +262 -0
- zerochat_runtime/assets/js/utils.js +216 -0
- zerochat_runtime/assets/js/vendor/orama.browser.js +11 -0
- zerochat_runtime/assets/js/web-browser.js +569 -0
- zerochat_runtime/assets/js/web-search.js +519 -0
- zerochat_runtime/assets/manifest.webmanifest +20 -0
- zerochat_runtime/assets/services/dummy_mcp/dummy_mcp_server.py +47 -0
- zerochat_runtime/assets/services/dummy_mcp/service.json +22 -0
- zerochat_runtime/assets/services/lsp/installer.json +9 -0
- zerochat_runtime/assets/services/lsp/service.json +22 -0
- zerochat_runtime/assets/services/memory/installer.json +9 -0
- zerochat_runtime/assets/services/memory/service.json +24 -0
- zerochat_runtime/assets/services/playwright/installer.json +10 -0
- zerochat_runtime/assets/services/playwright/service.json +39 -0
- zerochat_runtime/assets/sw.js +164 -0
- zerochat_runtime/assets/zerochat.html +671 -0
|
@@ -0,0 +1,1560 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Módulo de Adaptadores y Capacidades de Proveedores LLM (ChatProviders).
|
|
3
|
+
* Separa la lógica específica de cada proveedor (OpenAI, Claude, Gemini, Ollama, OpenRouter, Custom)
|
|
4
|
+
* de la capa común de transporte HTTP/SSE mediante un sistema declarativo de capacidades (Capabilities).
|
|
5
|
+
* Compatible con file://, http:// y Node.js.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
(function (root, factory) {
|
|
9
|
+
if (typeof exports === 'object' && typeof module !== 'undefined') {
|
|
10
|
+
module.exports = factory();
|
|
11
|
+
} else {
|
|
12
|
+
root.ChatProviders = factory();
|
|
13
|
+
}
|
|
14
|
+
})(typeof self !== 'undefined' ? self : this, function () {
|
|
15
|
+
'use strict';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Esquema estándar de capacidades soportadas por adaptadores de modelos LLM.
|
|
19
|
+
*/
|
|
20
|
+
const DEFAULT_CAPABILITIES = {
|
|
21
|
+
streaming: true, // Soporte para streaming de respuestas vía SSE
|
|
22
|
+
vision: true, // Soporte para procesamiento de imágenes multimodales
|
|
23
|
+
tools: true, // Soporte para Function / Tool Calling
|
|
24
|
+
reasoning: true, // Soporte para control de razonamiento (thinking / reasoning_effort)
|
|
25
|
+
jsonMode: true, // Soporte para structured outputs / response_format: { type: "json_object" }
|
|
26
|
+
promptCaching: true, // Soporte para Context / Prompt Caching efímero o persistente
|
|
27
|
+
embeddings: true, // Soporte para endpoints de generación de embeddings
|
|
28
|
+
modelListing: true // Soporte para descubrimiento automático de modelos (/models, /api/tags)
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
function serializeContent(content) {
|
|
32
|
+
if (typeof content === 'string') return content;
|
|
33
|
+
if (content === undefined) return '';
|
|
34
|
+
if (typeof content === 'object') return JSON.stringify(content);
|
|
35
|
+
return String(content);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Adaptador Base genérico (compatible con OpenAI, LM Studio, vLLM, LocalAI, DeepSeek).
|
|
40
|
+
*/
|
|
41
|
+
class BaseProviderAdapter {
|
|
42
|
+
constructor(options = {}) {
|
|
43
|
+
this.id = options.id || 'openai';
|
|
44
|
+
this.label = options.label || 'OpenAI / LM Studio';
|
|
45
|
+
this.description = options.description || 'Estándar OpenAI / LM Studio (reasoning_effort: none, low, medium, high, xhigh)';
|
|
46
|
+
this.reasoningLevels = options.reasoningLevels || ['none', 'low', 'medium', 'high', 'xhigh'];
|
|
47
|
+
this.connection = {
|
|
48
|
+
endpoint: options.connection?.endpoint || 'http://localhost:1234/v1',
|
|
49
|
+
knownEndpoints: Array.isArray(options.connection?.knownEndpoints) ? [...options.connection.knownEndpoints] : [],
|
|
50
|
+
endpointReadOnly: options.connection?.endpointReadOnly === true,
|
|
51
|
+
credentials: options.connection?.credentials !== false,
|
|
52
|
+
localModelManagement: options.connection?.localModelManagement === true
|
|
53
|
+
};
|
|
54
|
+
this.capabilities = {
|
|
55
|
+
...DEFAULT_CAPABILITIES,
|
|
56
|
+
...(options.capabilities || {})
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Obtiene el conjunto de capacidades del adaptador, permitiendo ajustes según el modelo.
|
|
62
|
+
*/
|
|
63
|
+
getCapabilities(model) {
|
|
64
|
+
return { ...this.capabilities };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
getConnectionConfig() {
|
|
68
|
+
return { ...this.connection, knownEndpoints: [...this.connection.knownEndpoints] };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async deactivate() {}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Normaliza la URL base al endpoint de chat del proveedor.
|
|
75
|
+
*/
|
|
76
|
+
normalizeEndpoint(rawUrl) {
|
|
77
|
+
let url = (rawUrl || 'http://localhost:1234/v1').trim();
|
|
78
|
+
if (url.endsWith('/')) url = url.slice(0, -1);
|
|
79
|
+
if (url.endsWith('/chat/completions')) return url;
|
|
80
|
+
if (url.endsWith('/v1')) return `${url}/chat/completions`;
|
|
81
|
+
return `${url}/v1/chat/completions`;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Construye las cabeceras HTTP necesarias para la petición.
|
|
86
|
+
*/
|
|
87
|
+
buildHeaders(apiKey) {
|
|
88
|
+
const headers = { 'Content-Type': 'application/json' };
|
|
89
|
+
if (apiKey && apiKey.trim() !== '') {
|
|
90
|
+
headers['Authorization'] = `Bearer ${apiKey.trim()}`;
|
|
91
|
+
}
|
|
92
|
+
return headers;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Adapta y formatea la lista de mensajes (filtrando imágenes si vision está deshabilitado).
|
|
97
|
+
*/
|
|
98
|
+
formatMessages(messages, capabilities) {
|
|
99
|
+
const caps = capabilities || this.getCapabilities();
|
|
100
|
+
if (caps.vision) {
|
|
101
|
+
return messages;
|
|
102
|
+
}
|
|
103
|
+
// Si el proveedor no soporta visión, degradar imágenes a texto plano
|
|
104
|
+
return messages.map(m => {
|
|
105
|
+
if (Array.isArray(m.content)) {
|
|
106
|
+
const textOnly = m.content
|
|
107
|
+
.filter(part => part.type === 'text')
|
|
108
|
+
.map(part => part.text)
|
|
109
|
+
.join('\n');
|
|
110
|
+
return { ...m, content: textOnly };
|
|
111
|
+
}
|
|
112
|
+
return m;
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Aplica la configuración de razonamiento / pensamiento al payload.
|
|
118
|
+
*/
|
|
119
|
+
applyReasoning(payload, effortLevel) {
|
|
120
|
+
let effort = String(effortLevel || 'none').toLowerCase().trim();
|
|
121
|
+
if (effort === 'off') effort = 'none';
|
|
122
|
+
payload.reasoning_effort = effort;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
supportsUsageStatistics() {
|
|
126
|
+
const id = String(this.id || '').toLowerCase();
|
|
127
|
+
return id !== 'ollama' && id !== 'gemini' && id !== 'claude';
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Aplica la solicitud de métricas detalladas de tokens si se trata de streaming.
|
|
132
|
+
*/
|
|
133
|
+
requestUsageStatistics(payload, stream = true) {
|
|
134
|
+
if (stream !== false && this.supportsUsageStatistics()) {
|
|
135
|
+
payload.stream_options = { include_usage: true };
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Aplica opciones específicas de caché de contexto (Prompt / KV Caching).
|
|
141
|
+
* En OpenAI/v1 compatible, la caché es gestionada automáticamente por el servidor según el prefijo,
|
|
142
|
+
* y las métricas se obtienen a través de requestUsageStatistics.
|
|
143
|
+
*/
|
|
144
|
+
applyContextCache(payload, options = {}) {
|
|
145
|
+
this.requestUsageStatistics(payload, options.stream !== false);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Aplica el modo de respuesta estructurada en formato JSON.
|
|
150
|
+
*/
|
|
151
|
+
applyJsonMode(payload) {
|
|
152
|
+
payload.response_format = { type: 'json_object' };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Inyecta las definiciones de herramientas agénticas.
|
|
157
|
+
*/
|
|
158
|
+
applyTools(payload, toolsList, toolChoice = 'auto') {
|
|
159
|
+
if (toolsList && toolsList.length > 0) {
|
|
160
|
+
payload.tools = toolsList;
|
|
161
|
+
payload.tool_choice = toolChoice;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Construye el payload completo para la petición HTTP de Chat Completion.
|
|
167
|
+
*/
|
|
168
|
+
buildPayload(params) {
|
|
169
|
+
const {
|
|
170
|
+
model = '',
|
|
171
|
+
messages = [],
|
|
172
|
+
temperature = 0.7,
|
|
173
|
+
reasoningEffort = 'medium',
|
|
174
|
+
reasoningTransport = 'auto',
|
|
175
|
+
toolsList = [],
|
|
176
|
+
toolChoice = 'auto',
|
|
177
|
+
stream = true,
|
|
178
|
+
jsonMode = false
|
|
179
|
+
} = params;
|
|
180
|
+
|
|
181
|
+
const capabilities = this.getCapabilities(model);
|
|
182
|
+
const formattedMessages = this.formatMessages(messages, capabilities);
|
|
183
|
+
|
|
184
|
+
const payload = {
|
|
185
|
+
model: (model || '').trim(),
|
|
186
|
+
messages: formattedMessages,
|
|
187
|
+
temperature: parseFloat(temperature) || 0.7
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
if (capabilities.streaming && stream !== false) {
|
|
191
|
+
payload.stream = true;
|
|
192
|
+
if (this.supportsUsageStatistics()) {
|
|
193
|
+
this.requestUsageStatistics(payload, true);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
if (capabilities.reasoning && !['none', 'off', ''].includes(String(reasoningEffort).toLowerCase().trim())) {
|
|
198
|
+
this.applyReasoning(payload, reasoningEffort);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (capabilities.tools && toolsList && toolsList.length > 0) {
|
|
202
|
+
this.applyTools(payload, toolsList, toolChoice);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if (capabilities.promptCaching) {
|
|
206
|
+
this.applyContextCache(payload, { toolsList, messages: formattedMessages, stream: stream !== false });
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if (capabilities.jsonMode && jsonMode) {
|
|
210
|
+
this.applyJsonMode(payload);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
return payload;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Parsea un evento SSE (data JSON) para extraer deltas de texto, pensamiento, tools y usage.
|
|
218
|
+
*/
|
|
219
|
+
parseStreamChunk(parsed, state) {
|
|
220
|
+
const result = {
|
|
221
|
+
textChunk: '',
|
|
222
|
+
reasoningChunk: '',
|
|
223
|
+
toolCallDeltas: [],
|
|
224
|
+
usage: null,
|
|
225
|
+
cachedTokens: 0,
|
|
226
|
+
cacheCreationTokens: 0
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
const choice = parsed.choices?.[0];
|
|
230
|
+
const delta = choice?.delta;
|
|
231
|
+
|
|
232
|
+
if (delta) {
|
|
233
|
+
// Tokens de razonamiento específicos
|
|
234
|
+
const rChunk = delta.reasoning_content || delta.reasoning || delta.thinking || delta.thought || '';
|
|
235
|
+
if (rChunk) {
|
|
236
|
+
result.reasoningChunk = rChunk;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// Contenido textual normal
|
|
240
|
+
const text = delta.content || delta.text || '';
|
|
241
|
+
if (text) {
|
|
242
|
+
result.textChunk = text;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// Llamadas a herramientas
|
|
246
|
+
if (delta.tool_calls && Array.isArray(delta.tool_calls)) {
|
|
247
|
+
result.toolCallDeltas = delta.tool_calls;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// Thought signature de Gemini / Google
|
|
251
|
+
const sig = delta.thought_signature || delta.extra_content?.google?.thought_signature || choice?.thought_signature || parsed.thought_signature;
|
|
252
|
+
if (sig) {
|
|
253
|
+
result.thoughtSignature = sig;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// Usage / Context Caching (OpenAI, Anthropic, Ollama)
|
|
258
|
+
let usage = parsed.usage || parsed.message?.usage;
|
|
259
|
+
if (!usage && (parsed.prompt_eval_count !== undefined || parsed.eval_count !== undefined)) {
|
|
260
|
+
const pEval = parsed.prompt_eval_count ?? 0;
|
|
261
|
+
const cEval = parsed.eval_count ?? 0;
|
|
262
|
+
usage = {
|
|
263
|
+
prompt_tokens: pEval,
|
|
264
|
+
completion_tokens: cEval,
|
|
265
|
+
total_tokens: pEval + cEval
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
if (usage) {
|
|
270
|
+
result.usage = usage;
|
|
271
|
+
if (usage.prompt_tokens_details?.cached_tokens) {
|
|
272
|
+
result.cachedTokens = usage.prompt_tokens_details.cached_tokens;
|
|
273
|
+
}
|
|
274
|
+
if (usage.cache_read_input_tokens) {
|
|
275
|
+
result.cachedTokens = usage.cache_read_input_tokens;
|
|
276
|
+
}
|
|
277
|
+
if (usage.cache_creation_input_tokens) {
|
|
278
|
+
result.cacheCreationTokens = usage.cache_creation_input_tokens;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
return result;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Evalúa si un error HTTP 400 permite auto-recuperación (reintento sin params rechazados).
|
|
287
|
+
*/
|
|
288
|
+
handleHttpError(status, serverErrorMsg, payload) {
|
|
289
|
+
const errLower = (serverErrorMsg || '').toLowerCase();
|
|
290
|
+
|
|
291
|
+
if (status === 400 && (payload.reasoning_effort || payload.thinking || payload.reasoning)) {
|
|
292
|
+
if (errLower.includes('reasoning') || errLower.includes('thinking') || errLower.includes('unexpected') || errLower.includes('unrecognized') || errLower.includes('extra') || errLower.includes('unknown')) {
|
|
293
|
+
delete payload.reasoning_effort;
|
|
294
|
+
delete payload.thinking;
|
|
295
|
+
delete payload.reasoning;
|
|
296
|
+
return { retry: true, reason: 'reasoning_rejected' };
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
if (status === 400 && payload.tools) {
|
|
301
|
+
if (errLower.includes('tool') || errLower.includes('function') || errLower.includes('unexpected') || errLower.includes('unrecognized')) {
|
|
302
|
+
delete payload.tools;
|
|
303
|
+
delete payload.tool_choice;
|
|
304
|
+
return { retry: true, reason: 'tools_rejected' };
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
return { retry: false };
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Devuelve endpoints candidatos para consultar modelos disponibles.
|
|
313
|
+
*/
|
|
314
|
+
getModelEndpoints(cleanUrl) {
|
|
315
|
+
const v1Url = cleanUrl.endsWith('/v1') ? cleanUrl : `${cleanUrl}/v1`;
|
|
316
|
+
const baseWithoutV1 = cleanUrl.replace(/\/v1$/, '');
|
|
317
|
+
// LM Studio exposes the active context length only through /api/v0/models.
|
|
318
|
+
return [`${baseWithoutV1}/api/v0/models`, `${v1Url}/models`];
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Parsea la respuesta del endpoint de modelos.
|
|
323
|
+
*/
|
|
324
|
+
parseModelsResponse(data) {
|
|
325
|
+
if (data && Array.isArray(data.data)) {
|
|
326
|
+
return data.data.map(item => {
|
|
327
|
+
if (typeof item === 'string') return { id: item, name: item };
|
|
328
|
+
return {
|
|
329
|
+
id: item.id || item.name || '',
|
|
330
|
+
name: item.id || item.name || '',
|
|
331
|
+
owned_by: item.owned_by,
|
|
332
|
+
details: item
|
|
333
|
+
};
|
|
334
|
+
}).filter(m => !!m.id);
|
|
335
|
+
}
|
|
336
|
+
if (Array.isArray(data)) {
|
|
337
|
+
return data.map(item => {
|
|
338
|
+
if (typeof item === 'string') return { id: item, name: item };
|
|
339
|
+
return {
|
|
340
|
+
id: item.id || item.name || '',
|
|
341
|
+
name: item.id || item.name || '',
|
|
342
|
+
details: item
|
|
343
|
+
};
|
|
344
|
+
}).filter(m => !!m.id);
|
|
345
|
+
}
|
|
346
|
+
return [];
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* Devuelve metadatos para la configuración de razonamiento.
|
|
351
|
+
*/
|
|
352
|
+
getReasoningConfig() {
|
|
353
|
+
return {
|
|
354
|
+
type: this.id,
|
|
355
|
+
label: this.label,
|
|
356
|
+
levels: this.reasoningLevels,
|
|
357
|
+
transportOptions: this.capabilities.reasoning ? [] : ['omit', 'send-none'],
|
|
358
|
+
description: this.description
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* Inspecciona y diagnostica el endpoint del proveedor para determinar sus capacidades reales.
|
|
364
|
+
* Distingue entre:
|
|
365
|
+
* - 'declared': Declarada por el ProviderAdapter.
|
|
366
|
+
* - 'inferred': Inferida por heurística de nombres de modelos / endpoints.
|
|
367
|
+
* - 'confirmed': Comprobada mediante una prueba HTTP/SSE activa ultra-ligera (max_tokens: 1).
|
|
368
|
+
* - 'unsupported': Rechazada explícitamente o no soportada.
|
|
369
|
+
* - 'unknown': No determinada.
|
|
370
|
+
*/
|
|
371
|
+
async inspect(params = {}) {
|
|
372
|
+
const {
|
|
373
|
+
apiUrl = '',
|
|
374
|
+
apiKey = '',
|
|
375
|
+
model = '',
|
|
376
|
+
runProbes = true,
|
|
377
|
+
timeoutMs = 6000
|
|
378
|
+
} = params;
|
|
379
|
+
|
|
380
|
+
const startTime = typeof performance !== 'undefined' ? performance.now() : Date.now();
|
|
381
|
+
const normalizedEndpoint = this.normalizeEndpoint(apiUrl);
|
|
382
|
+
let cleanBase = (apiUrl || '').trim().replace(/\/+$/, '');
|
|
383
|
+
if (cleanBase.endsWith('/chat/completions')) cleanBase = cleanBase.replace(/\/chat\/completions$/, '');
|
|
384
|
+
|
|
385
|
+
const adapterCaps = this.getCapabilities(model);
|
|
386
|
+
|
|
387
|
+
// 1. Inicializar matriz de capacidades con el estado 'declared' o 'unsupported' según el adaptador
|
|
388
|
+
const capabilities = {
|
|
389
|
+
streaming: {
|
|
390
|
+
status: adapterCaps.streaming ? 'declared' : 'unsupported',
|
|
391
|
+
detail: adapterCaps.streaming ? 'Declarada en el adaptador' : 'No soportada según adaptador',
|
|
392
|
+
source: 'adapter'
|
|
393
|
+
},
|
|
394
|
+
tools: {
|
|
395
|
+
status: adapterCaps.tools ? 'declared' : 'unsupported',
|
|
396
|
+
detail: adapterCaps.tools ? 'Declarada en el adaptador' : 'No soportada según adaptador',
|
|
397
|
+
source: 'adapter'
|
|
398
|
+
},
|
|
399
|
+
vision: {
|
|
400
|
+
status: adapterCaps.vision ? 'declared' : 'unsupported',
|
|
401
|
+
detail: adapterCaps.vision ? 'Declarada en el adaptador' : 'No soportada según adaptador',
|
|
402
|
+
source: 'adapter'
|
|
403
|
+
},
|
|
404
|
+
reasoning: {
|
|
405
|
+
status: adapterCaps.reasoning ? 'declared' : 'unsupported',
|
|
406
|
+
detail: adapterCaps.reasoning ? 'Declarada en el adaptador' : 'No soportada según adaptador',
|
|
407
|
+
source: 'adapter'
|
|
408
|
+
},
|
|
409
|
+
jsonMode: {
|
|
410
|
+
status: adapterCaps.jsonMode ? 'declared' : 'unsupported',
|
|
411
|
+
detail: adapterCaps.jsonMode ? 'Declarada en el adaptador' : 'No soportada según adaptador',
|
|
412
|
+
source: 'adapter'
|
|
413
|
+
},
|
|
414
|
+
promptCaching: {
|
|
415
|
+
status: adapterCaps.promptCaching ? 'declared' : 'unknown',
|
|
416
|
+
detail: adapterCaps.promptCaching ? 'Declarada en el adaptador' : 'Soporte no determinado',
|
|
417
|
+
source: 'adapter'
|
|
418
|
+
},
|
|
419
|
+
embeddings: {
|
|
420
|
+
status: adapterCaps.embeddings ? 'declared' : 'unknown',
|
|
421
|
+
detail: adapterCaps.embeddings ? 'Declarada en el adaptador' : 'Soporte no determinado',
|
|
422
|
+
source: 'adapter'
|
|
423
|
+
},
|
|
424
|
+
modelListing: {
|
|
425
|
+
status: adapterCaps.modelListing ? 'declared' : 'unsupported',
|
|
426
|
+
detail: adapterCaps.modelListing ? 'Declarada en el adaptador' : 'No soportada según adaptador',
|
|
427
|
+
source: 'adapter'
|
|
428
|
+
}
|
|
429
|
+
};
|
|
430
|
+
|
|
431
|
+
let discoveredModels = [];
|
|
432
|
+
let modelListSuccess = false;
|
|
433
|
+
let connectionAttempted = false;
|
|
434
|
+
let connectionSuccess = false;
|
|
435
|
+
let authError = null;
|
|
436
|
+
let lastNetworkError = null;
|
|
437
|
+
let lastHttpStatus = null;
|
|
438
|
+
|
|
439
|
+
// 2. Comprobar listado de modelos y realizar inferencias
|
|
440
|
+
const modelEndpoints = this.getModelEndpoints(cleanBase);
|
|
441
|
+
const headers = this.buildHeaders(apiKey);
|
|
442
|
+
|
|
443
|
+
if (runProbes && typeof fetch === 'function') {
|
|
444
|
+
for (const endpoint of modelEndpoints) {
|
|
445
|
+
connectionAttempted = true;
|
|
446
|
+
let timer = null;
|
|
447
|
+
try {
|
|
448
|
+
const controller = typeof AbortController !== 'undefined' ? new AbortController() : null;
|
|
449
|
+
if (controller) {
|
|
450
|
+
timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
451
|
+
if (typeof timer?.unref === 'function') timer.unref();
|
|
452
|
+
}
|
|
453
|
+
const res = await fetch(endpoint, {
|
|
454
|
+
method: 'GET',
|
|
455
|
+
headers: { Accept: 'application/json', ...headers },
|
|
456
|
+
signal: controller ? controller.signal : undefined
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
lastHttpStatus = res.status;
|
|
460
|
+
if (res.ok) {
|
|
461
|
+
connectionSuccess = true;
|
|
462
|
+
const data = await res.json();
|
|
463
|
+
const parsed = this.parseModelsResponse(data);
|
|
464
|
+
if (Array.isArray(parsed) && parsed.length > 0) {
|
|
465
|
+
discoveredModels = parsed;
|
|
466
|
+
modelListSuccess = true;
|
|
467
|
+
capabilities.modelListing = {
|
|
468
|
+
status: 'confirmed',
|
|
469
|
+
detail: `${parsed.length} modelo(s) descubierto(s) en ${endpoint}`,
|
|
470
|
+
source: 'probe'
|
|
471
|
+
};
|
|
472
|
+
break;
|
|
473
|
+
}
|
|
474
|
+
} else if (res.status === 401 || res.status === 403) {
|
|
475
|
+
authError = `Error de autenticación (HTTP ${res.status}): Clave de API no válida o acceso denegado.`;
|
|
476
|
+
}
|
|
477
|
+
} catch (e) {
|
|
478
|
+
lastNetworkError = e;
|
|
479
|
+
} finally {
|
|
480
|
+
if (timer) clearTimeout(timer);
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
if (!modelListSuccess && adapterCaps.modelListing) {
|
|
486
|
+
capabilities.modelListing = {
|
|
487
|
+
status: 'unknown',
|
|
488
|
+
detail: 'No se pudo consultar el listado de modelos en los endpoints estándar',
|
|
489
|
+
source: 'probe'
|
|
490
|
+
};
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
// Inferencias por nombre de modelo seleccionado o lista de modelos descubiertos
|
|
494
|
+
const targetModel = (model || (discoveredModels[0] && discoveredModels[0].id) || '').toLowerCase();
|
|
495
|
+
const allModelNames = discoveredModels.map(m => (m.id || m.name || '').toLowerCase()).join(' ');
|
|
496
|
+
const combinedModelContext = (targetModel + ' ' + allModelNames).trim();
|
|
497
|
+
|
|
498
|
+
if (combinedModelContext) {
|
|
499
|
+
// Inferencia de Visión
|
|
500
|
+
if (/(?:-vl|-vision|vision|4o|sonnet|opus|flash|pixtral|llava|cogvlm|gemini|qwen.*vl)/i.test(combinedModelContext)) {
|
|
501
|
+
capabilities.vision = {
|
|
502
|
+
status: 'inferred',
|
|
503
|
+
detail: 'Inferida por identificador multimodal detectado en los modelos',
|
|
504
|
+
source: 'model_name'
|
|
505
|
+
};
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
// Inferencia de Razonamiento
|
|
509
|
+
if (/(?:-r1|r1|qwq|o1|o3|reasoning|thinking|gemma-4|deepseek)/i.test(combinedModelContext)) {
|
|
510
|
+
capabilities.reasoning = {
|
|
511
|
+
status: 'inferred',
|
|
512
|
+
detail: 'Inferida por identificador de modelo con razonamiento/pensamiento',
|
|
513
|
+
source: 'model_name'
|
|
514
|
+
};
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
// Inferencia de Embeddings
|
|
518
|
+
if (/(?:embedding|embed|bge|nomic|e5|text-embedding)/i.test(combinedModelContext)) {
|
|
519
|
+
capabilities.embeddings = {
|
|
520
|
+
status: 'inferred',
|
|
521
|
+
detail: 'Inferida por modelos de embeddings presentes en el catálogo',
|
|
522
|
+
source: 'model_name'
|
|
523
|
+
};
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
// 3. Pruebas activas controladas (Micro-sondas seguras con max_tokens: 1)
|
|
528
|
+
const probeModel = model || (discoveredModels[0] && discoveredModels[0].id) || '';
|
|
529
|
+
let probesRun = false;
|
|
530
|
+
|
|
531
|
+
if (runProbes && typeof fetch === 'function') {
|
|
532
|
+
probesRun = true;
|
|
533
|
+
|
|
534
|
+
// Micro-sonda A: Streaming & Chat básico
|
|
535
|
+
connectionAttempted = true;
|
|
536
|
+
let timerA = null;
|
|
537
|
+
try {
|
|
538
|
+
const controller = typeof AbortController !== 'undefined' ? new AbortController() : null;
|
|
539
|
+
if (controller) {
|
|
540
|
+
timerA = setTimeout(() => controller.abort(), timeoutMs);
|
|
541
|
+
if (typeof timerA?.unref === 'function') timerA.unref();
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
const probePayload = this.buildPayload({
|
|
545
|
+
model: probeModel,
|
|
546
|
+
messages: [{ role: 'user', content: 'hi' }],
|
|
547
|
+
stream: true,
|
|
548
|
+
temperature: 0.1,
|
|
549
|
+
reasoningEffort: 'none'
|
|
550
|
+
});
|
|
551
|
+
probePayload.max_tokens = 1;
|
|
552
|
+
|
|
553
|
+
const res = await fetch(normalizedEndpoint, {
|
|
554
|
+
method: 'POST',
|
|
555
|
+
headers: { 'Content-Type': 'application/json', ...headers },
|
|
556
|
+
body: JSON.stringify(probePayload),
|
|
557
|
+
signal: controller ? controller.signal : undefined
|
|
558
|
+
});
|
|
559
|
+
|
|
560
|
+
lastHttpStatus = res.status;
|
|
561
|
+
if (res.ok) {
|
|
562
|
+
connectionSuccess = true;
|
|
563
|
+
const contentType = res.headers && res.headers.get ? (res.headers.get('content-type') || '') : '';
|
|
564
|
+
if (contentType.includes('event-stream') || res.body) {
|
|
565
|
+
capabilities.streaming = {
|
|
566
|
+
status: 'confirmed',
|
|
567
|
+
detail: 'Respuesta de streaming SSE (HTTP 200) verificada exitosamente',
|
|
568
|
+
source: 'probe'
|
|
569
|
+
};
|
|
570
|
+
}
|
|
571
|
+
} else if (res.status === 401 || res.status === 403) {
|
|
572
|
+
authError = `Error de autenticación (HTTP ${res.status}): Clave de API no válida o acceso denegado.`;
|
|
573
|
+
} else if (res.status === 400 || res.status === 422) {
|
|
574
|
+
connectionSuccess = true;
|
|
575
|
+
const errText = await res.text().catch(() => '');
|
|
576
|
+
if (errText.toLowerCase().includes('stream')) {
|
|
577
|
+
capabilities.streaming = {
|
|
578
|
+
status: 'unsupported',
|
|
579
|
+
detail: `Servidor rechazó streaming: ${errText.substring(0, 80)}`,
|
|
580
|
+
source: 'probe'
|
|
581
|
+
};
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
} catch (probeErr) {
|
|
585
|
+
lastNetworkError = probeErr;
|
|
586
|
+
} finally {
|
|
587
|
+
if (timerA) clearTimeout(timerA);
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
// Micro-sonda B: Tools / Function Calling
|
|
591
|
+
connectionAttempted = true;
|
|
592
|
+
let timerB = null;
|
|
593
|
+
try {
|
|
594
|
+
const controller = typeof AbortController !== 'undefined' ? new AbortController() : null;
|
|
595
|
+
if (controller) {
|
|
596
|
+
timerB = setTimeout(() => controller.abort(), timeoutMs);
|
|
597
|
+
if (typeof timerB?.unref === 'function') timerB.unref();
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
const toolPayload = this.buildPayload({
|
|
601
|
+
model: probeModel,
|
|
602
|
+
messages: [{ role: 'user', content: 'hi' }],
|
|
603
|
+
stream: false,
|
|
604
|
+
toolsList: [{
|
|
605
|
+
type: 'function',
|
|
606
|
+
function: {
|
|
607
|
+
name: 'ping_test',
|
|
608
|
+
description: 'Inspector ping test',
|
|
609
|
+
parameters: { type: 'object', properties: {} }
|
|
610
|
+
}
|
|
611
|
+
}]
|
|
612
|
+
});
|
|
613
|
+
toolPayload.max_tokens = 1;
|
|
614
|
+
toolPayload.tool_choice = 'none';
|
|
615
|
+
|
|
616
|
+
const res = await fetch(normalizedEndpoint, {
|
|
617
|
+
method: 'POST',
|
|
618
|
+
headers: { 'Content-Type': 'application/json', ...headers },
|
|
619
|
+
body: JSON.stringify(toolPayload),
|
|
620
|
+
signal: controller ? controller.signal : undefined
|
|
621
|
+
});
|
|
622
|
+
|
|
623
|
+
lastHttpStatus = res.status;
|
|
624
|
+
if (res.ok) {
|
|
625
|
+
connectionSuccess = true;
|
|
626
|
+
capabilities.tools = {
|
|
627
|
+
status: 'confirmed',
|
|
628
|
+
detail: 'El servidor aceptó el esquema de tools/functions (HTTP 200)',
|
|
629
|
+
source: 'probe'
|
|
630
|
+
};
|
|
631
|
+
} else if (res.status === 401 || res.status === 403) {
|
|
632
|
+
authError = `Error de autenticación (HTTP ${res.status}): Clave de API no válida o acceso denegado.`;
|
|
633
|
+
} else if (res.status === 400 || res.status === 422) {
|
|
634
|
+
connectionSuccess = true;
|
|
635
|
+
const errText = await res.text().catch(() => '');
|
|
636
|
+
if (errText.toLowerCase().includes('tool') || errText.toLowerCase().includes('function') || errText.toLowerCase().includes('schema')) {
|
|
637
|
+
capabilities.tools = {
|
|
638
|
+
status: 'unsupported',
|
|
639
|
+
detail: `Servidor rechazó tools: ${errText.substring(0, 80)}`,
|
|
640
|
+
source: 'probe'
|
|
641
|
+
};
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
} catch (e) {
|
|
645
|
+
lastNetworkError = e;
|
|
646
|
+
} finally {
|
|
647
|
+
if (timerB) clearTimeout(timerB);
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
// Micro-sonda C: JSON Mode
|
|
651
|
+
connectionAttempted = true;
|
|
652
|
+
let timerC = null;
|
|
653
|
+
try {
|
|
654
|
+
const controller = typeof AbortController !== 'undefined' ? new AbortController() : null;
|
|
655
|
+
if (controller) {
|
|
656
|
+
timerC = setTimeout(() => controller.abort(), timeoutMs);
|
|
657
|
+
if (typeof timerC?.unref === 'function') timerC.unref();
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
const jsonPayload = this.buildPayload({
|
|
661
|
+
model: probeModel,
|
|
662
|
+
messages: [{ role: 'user', content: 'hi' }],
|
|
663
|
+
stream: false,
|
|
664
|
+
jsonMode: true
|
|
665
|
+
});
|
|
666
|
+
jsonPayload.max_tokens = 1;
|
|
667
|
+
|
|
668
|
+
const res = await fetch(normalizedEndpoint, {
|
|
669
|
+
method: 'POST',
|
|
670
|
+
headers: { 'Content-Type': 'application/json', ...headers },
|
|
671
|
+
body: JSON.stringify(jsonPayload),
|
|
672
|
+
signal: controller ? controller.signal : undefined
|
|
673
|
+
});
|
|
674
|
+
|
|
675
|
+
lastHttpStatus = res.status;
|
|
676
|
+
if (res.ok) {
|
|
677
|
+
connectionSuccess = true;
|
|
678
|
+
capabilities.jsonMode = {
|
|
679
|
+
status: 'confirmed',
|
|
680
|
+
detail: 'El servidor aceptó response_format: json_object (HTTP 200)',
|
|
681
|
+
source: 'probe'
|
|
682
|
+
};
|
|
683
|
+
} else if (res.status === 401 || res.status === 403) {
|
|
684
|
+
authError = `Error de autenticación (HTTP ${res.status}): Clave de API no válida o acceso denegado.`;
|
|
685
|
+
} else if (res.status === 400 || res.status === 422) {
|
|
686
|
+
connectionSuccess = true;
|
|
687
|
+
const errText = await res.text().catch(() => '');
|
|
688
|
+
if (errText.toLowerCase().includes('response_format') || errText.toLowerCase().includes('json')) {
|
|
689
|
+
capabilities.jsonMode = {
|
|
690
|
+
status: 'unsupported',
|
|
691
|
+
detail: `Servidor rechazó json_object: ${errText.substring(0, 80)}`,
|
|
692
|
+
source: 'probe'
|
|
693
|
+
};
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
} catch (e) {
|
|
697
|
+
lastNetworkError = e;
|
|
698
|
+
} finally {
|
|
699
|
+
if (timerC) clearTimeout(timerC);
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
// Micro-sonda D: Embeddings endpoint check
|
|
703
|
+
let timerD = null;
|
|
704
|
+
try {
|
|
705
|
+
const embUrl = cleanBase.endsWith('/v1') ? `${cleanBase}/embeddings` : `${cleanBase}/v1/embeddings`;
|
|
706
|
+
const controller = typeof AbortController !== 'undefined' ? new AbortController() : null;
|
|
707
|
+
if (controller) {
|
|
708
|
+
timerD = setTimeout(() => controller.abort(), timeoutMs);
|
|
709
|
+
if (typeof timerD?.unref === 'function') timerD.unref();
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
const res = await fetch(embUrl, {
|
|
713
|
+
method: 'POST',
|
|
714
|
+
headers: { 'Content-Type': 'application/json', ...headers },
|
|
715
|
+
body: JSON.stringify({ input: 'ping', model: probeModel }),
|
|
716
|
+
signal: controller ? controller.signal : undefined
|
|
717
|
+
});
|
|
718
|
+
|
|
719
|
+
if (res.ok) {
|
|
720
|
+
connectionSuccess = true;
|
|
721
|
+
capabilities.embeddings = {
|
|
722
|
+
status: 'confirmed',
|
|
723
|
+
detail: `Endpoint ${embUrl} responde correctamente (HTTP 200)`,
|
|
724
|
+
source: 'probe'
|
|
725
|
+
};
|
|
726
|
+
} else if (res.status === 404) {
|
|
727
|
+
capabilities.embeddings = {
|
|
728
|
+
status: 'unsupported',
|
|
729
|
+
detail: `Endpoint ${embUrl} no encontrado (404)`,
|
|
730
|
+
source: 'probe'
|
|
731
|
+
};
|
|
732
|
+
} else if (res.status === 401 || res.status === 403) {
|
|
733
|
+
authError = `Error de autenticación (HTTP ${res.status}): Clave de API no válida o acceso denegado.`;
|
|
734
|
+
} else if (res.status === 400 || res.status === 422) {
|
|
735
|
+
connectionSuccess = true;
|
|
736
|
+
}
|
|
737
|
+
} catch (e) {} finally {
|
|
738
|
+
if (timerD) clearTimeout(timerD);
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
const endTime = typeof performance !== 'undefined' ? performance.now() : Date.now();
|
|
743
|
+
|
|
744
|
+
// Si se intentó conectar mediante sondas y ninguna tuvo éxito, informar fallo de conexión
|
|
745
|
+
if (runProbes && connectionAttempted && !connectionSuccess) {
|
|
746
|
+
let errorMsg;
|
|
747
|
+
if (authError) {
|
|
748
|
+
errorMsg = authError;
|
|
749
|
+
} else if (lastNetworkError) {
|
|
750
|
+
const netMsg = lastNetworkError.message || String(lastNetworkError);
|
|
751
|
+
errorMsg = `Error de conexión: No se pudo conectar con el servidor en ${normalizedEndpoint} (${netMsg}). Verifica que el servidor esté activo y la URL sea correcta.`;
|
|
752
|
+
} else if (lastHttpStatus) {
|
|
753
|
+
errorMsg = `Error de conexión: El servidor respondió con error HTTP ${lastHttpStatus} en ${normalizedEndpoint}. Verifica el endpoint y la configuración.`;
|
|
754
|
+
} else {
|
|
755
|
+
errorMsg = `Error de conexión: No se pudo establecer comunicación con el servidor en ${normalizedEndpoint}.`;
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
Object.keys(capabilities).forEach(k => {
|
|
759
|
+
capabilities[k] = {
|
|
760
|
+
status: 'unknown',
|
|
761
|
+
detail: 'No comprobada: fallo de conexión con el servidor',
|
|
762
|
+
source: 'probe'
|
|
763
|
+
};
|
|
764
|
+
});
|
|
765
|
+
|
|
766
|
+
return {
|
|
767
|
+
success: false,
|
|
768
|
+
connected: false,
|
|
769
|
+
error: errorMsg,
|
|
770
|
+
provider: {
|
|
771
|
+
id: this.id,
|
|
772
|
+
label: this.label,
|
|
773
|
+
description: this.description
|
|
774
|
+
},
|
|
775
|
+
endpoint: {
|
|
776
|
+
raw: apiUrl,
|
|
777
|
+
normalized: normalizedEndpoint,
|
|
778
|
+
base: cleanBase
|
|
779
|
+
},
|
|
780
|
+
model: {
|
|
781
|
+
selected: probeModel,
|
|
782
|
+
totalDiscovered: 0,
|
|
783
|
+
discovered: []
|
|
784
|
+
},
|
|
785
|
+
capabilities: capabilities,
|
|
786
|
+
probesRun: true,
|
|
787
|
+
inspectionTimeMs: Math.round(endTime - startTime)
|
|
788
|
+
};
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
// Devolver resultado estructurado de la inspección SIN apiKey
|
|
792
|
+
return {
|
|
793
|
+
success: true,
|
|
794
|
+
connected: runProbes ? true : null,
|
|
795
|
+
provider: {
|
|
796
|
+
id: this.id,
|
|
797
|
+
label: this.label,
|
|
798
|
+
description: this.description
|
|
799
|
+
},
|
|
800
|
+
endpoint: {
|
|
801
|
+
raw: apiUrl,
|
|
802
|
+
normalized: normalizedEndpoint,
|
|
803
|
+
base: cleanBase
|
|
804
|
+
},
|
|
805
|
+
model: {
|
|
806
|
+
selected: probeModel,
|
|
807
|
+
totalDiscovered: discoveredModels.length,
|
|
808
|
+
discovered: discoveredModels
|
|
809
|
+
},
|
|
810
|
+
capabilities: capabilities,
|
|
811
|
+
probesRun: probesRun,
|
|
812
|
+
inspectionTimeMs: Math.round(endTime - startTime)
|
|
813
|
+
};
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
/**
|
|
818
|
+
* Adaptador para Anthropic Claude (/v1/messages).
|
|
819
|
+
*/
|
|
820
|
+
class ClaudeProviderAdapter extends BaseProviderAdapter {
|
|
821
|
+
constructor() {
|
|
822
|
+
super({
|
|
823
|
+
id: 'claude',
|
|
824
|
+
label: 'Anthropic Claude',
|
|
825
|
+
connection: { endpoint: 'https://api.anthropic.com/v1' },
|
|
826
|
+
description: 'Estándar Claude (thinking budget: disabled, 1k, 2k, 4k, 8k tokens)',
|
|
827
|
+
reasoningLevels: ['none', 'low', 'medium', 'high', 'xhigh'],
|
|
828
|
+
capabilities: {
|
|
829
|
+
streaming: true,
|
|
830
|
+
vision: true,
|
|
831
|
+
tools: true,
|
|
832
|
+
reasoning: true,
|
|
833
|
+
jsonMode: false,
|
|
834
|
+
promptCaching: true,
|
|
835
|
+
embeddings: false,
|
|
836
|
+
modelListing: true
|
|
837
|
+
}
|
|
838
|
+
});
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
normalizeEndpoint(rawUrl) {
|
|
842
|
+
let url = (rawUrl || 'https://api.anthropic.com/v1').trim();
|
|
843
|
+
if (url.endsWith('/')) url = url.slice(0, -1);
|
|
844
|
+
if (url.endsWith('/v1/messages') || url.endsWith('/messages') || url.endsWith('/chat/completions')) return url;
|
|
845
|
+
if (url.endsWith('/v1')) return `${url}/messages`;
|
|
846
|
+
return `${url}/v1/messages`;
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
/**
|
|
850
|
+
* Construye las cabeceras HTTP necesarias para Anthropic directo o proxies.
|
|
851
|
+
*/
|
|
852
|
+
buildHeaders(apiKey) {
|
|
853
|
+
const headers = super.buildHeaders(apiKey);
|
|
854
|
+
// Cabecera obligatoria de versión para API directa de Anthropic (https://api.anthropic.com/v1)
|
|
855
|
+
headers['anthropic-version'] = '2023-06-01';
|
|
856
|
+
if (apiKey && apiKey.trim() !== '') {
|
|
857
|
+
headers['x-api-key'] = apiKey.trim();
|
|
858
|
+
}
|
|
859
|
+
return headers;
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
formatMessages(messages, capabilities) {
|
|
863
|
+
const caps = capabilities || this.getCapabilities();
|
|
864
|
+
return messages.map(m => {
|
|
865
|
+
if (Array.isArray(m.content)) {
|
|
866
|
+
const claudeParts = m.content.map(part => {
|
|
867
|
+
if (caps.vision && part.type === 'image_url' && part.image_url && part.image_url.url) {
|
|
868
|
+
const match = part.image_url.url.match(/^data:([^;]+);base64,(.+)$/);
|
|
869
|
+
if (match) {
|
|
870
|
+
return {
|
|
871
|
+
type: 'image',
|
|
872
|
+
source: {
|
|
873
|
+
type: 'base64',
|
|
874
|
+
media_type: match[1],
|
|
875
|
+
data: match[2]
|
|
876
|
+
}
|
|
877
|
+
};
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
return part;
|
|
881
|
+
}).filter(part => caps.vision || part.type !== 'image');
|
|
882
|
+
return { ...m, content: claudeParts };
|
|
883
|
+
}
|
|
884
|
+
return m;
|
|
885
|
+
});
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
buildPayload(params) {
|
|
889
|
+
const {
|
|
890
|
+
model = '',
|
|
891
|
+
messages = [],
|
|
892
|
+
temperature = 0.7,
|
|
893
|
+
reasoningEffort = 'medium',
|
|
894
|
+
toolsList = [],
|
|
895
|
+
toolChoice = 'auto',
|
|
896
|
+
stream = true
|
|
897
|
+
} = params;
|
|
898
|
+
|
|
899
|
+
const capabilities = this.getCapabilities(model);
|
|
900
|
+
const formattedMessages = this.formatMessages(messages, capabilities);
|
|
901
|
+
|
|
902
|
+
// Separar system prompt (requerido a nivel raíz en Claude Messages API)
|
|
903
|
+
const systemMessages = formattedMessages.filter(m => m.role === 'system');
|
|
904
|
+
const nonSystemMessages = formattedMessages.filter(m => m.role !== 'system');
|
|
905
|
+
|
|
906
|
+
let systemContent = '';
|
|
907
|
+
if (systemMessages.length > 0) {
|
|
908
|
+
systemContent = systemMessages.map(m => typeof m.content === 'string' ? m.content : JSON.stringify(m.content)).join('\n\n');
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
const payload = {
|
|
912
|
+
model: (model || '').trim(),
|
|
913
|
+
messages: nonSystemMessages,
|
|
914
|
+
temperature: parseFloat(temperature) || 0.7,
|
|
915
|
+
max_tokens: 4096
|
|
916
|
+
};
|
|
917
|
+
|
|
918
|
+
if (systemContent) {
|
|
919
|
+
if (capabilities.promptCaching) {
|
|
920
|
+
payload.system = [{ type: 'text', text: systemContent, cache_control: { type: 'ephemeral' } }];
|
|
921
|
+
} else {
|
|
922
|
+
payload.system = systemContent;
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
if (capabilities.streaming && stream !== false) {
|
|
927
|
+
payload.stream = true;
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
if (capabilities.reasoning && !['none', 'off', ''].includes(String(reasoningEffort).toLowerCase().trim())) {
|
|
931
|
+
this.applyReasoning(payload, reasoningEffort);
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
if (capabilities.tools && toolsList && toolsList.length > 0) {
|
|
935
|
+
payload.tools = toolsList.map(t => {
|
|
936
|
+
if (t.type === 'function' && t.function) {
|
|
937
|
+
return {
|
|
938
|
+
name: t.function.name,
|
|
939
|
+
description: t.function.description || '',
|
|
940
|
+
input_schema: t.function.parameters || { type: 'object', properties: {} }
|
|
941
|
+
};
|
|
942
|
+
}
|
|
943
|
+
return t;
|
|
944
|
+
});
|
|
945
|
+
if (capabilities.promptCaching && payload.tools.length > 0) {
|
|
946
|
+
payload.tools[payload.tools.length - 1].cache_control = { type: 'ephemeral' };
|
|
947
|
+
}
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
if (capabilities.promptCaching) {
|
|
951
|
+
this.applyContextCache(payload, { toolsList, messages: nonSystemMessages });
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
return payload;
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
applyReasoning(payload, effortLevel) {
|
|
958
|
+
let effort = String(effortLevel || 'none').toLowerCase().trim();
|
|
959
|
+
if (effort === 'off') effort = 'none';
|
|
960
|
+
|
|
961
|
+
if (effort !== 'none') {
|
|
962
|
+
let budget = 2048;
|
|
963
|
+
if (effort === 'low' || effort === 'minimal') budget = 1024;
|
|
964
|
+
else if (effort === 'medium') budget = 2048;
|
|
965
|
+
else if (effort === 'high') budget = 4096;
|
|
966
|
+
else if (effort === 'xhigh') budget = 8192;
|
|
967
|
+
|
|
968
|
+
payload.thinking = {
|
|
969
|
+
type: 'enabled',
|
|
970
|
+
budget_tokens: budget
|
|
971
|
+
};
|
|
972
|
+
payload.temperature = 1.0;
|
|
973
|
+
payload.max_tokens = Math.max(4096, budget + 1024);
|
|
974
|
+
} else {
|
|
975
|
+
payload.thinking = { type: 'disabled' };
|
|
976
|
+
}
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
applyContextCache(payload, options = {}) {
|
|
980
|
+
const { toolsList = [], messages = [] } = options;
|
|
981
|
+
|
|
982
|
+
if (toolsList.length > 0 && !toolsList[toolsList.length - 1].cache_control) {
|
|
983
|
+
toolsList[toolsList.length - 1].cache_control = { type: 'ephemeral' };
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
// Localizar el último turno procesable (user o tool) para anclar el punto dinámico de caché
|
|
987
|
+
let lastProcessableIdx = -1;
|
|
988
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
989
|
+
if (messages[i].role === 'user' || messages[i].role === 'tool') {
|
|
990
|
+
lastProcessableIdx = i;
|
|
991
|
+
break;
|
|
992
|
+
}
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
payload.messages = messages.map((m, idx) => {
|
|
996
|
+
if (m.role === 'system') {
|
|
997
|
+
if (typeof m.content === 'string') {
|
|
998
|
+
return {
|
|
999
|
+
...m,
|
|
1000
|
+
content: [{ type: 'text', text: m.content, cache_control: { type: 'ephemeral' } }]
|
|
1001
|
+
};
|
|
1002
|
+
} else if (Array.isArray(m.content) && m.content.length > 0) {
|
|
1003
|
+
const updated = [...m.content];
|
|
1004
|
+
updated[updated.length - 1] = {
|
|
1005
|
+
...updated[updated.length - 1],
|
|
1006
|
+
cache_control: { type: 'ephemeral' }
|
|
1007
|
+
};
|
|
1008
|
+
return { ...m, content: updated };
|
|
1009
|
+
}
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
if (idx === lastProcessableIdx) {
|
|
1013
|
+
if (typeof m.content === 'string') {
|
|
1014
|
+
return {
|
|
1015
|
+
...m,
|
|
1016
|
+
content: [{ type: 'text', text: m.content, cache_control: { type: 'ephemeral' } }]
|
|
1017
|
+
};
|
|
1018
|
+
} else if (Array.isArray(m.content) && m.content.length > 0) {
|
|
1019
|
+
const updated = [...m.content];
|
|
1020
|
+
updated[updated.length - 1] = {
|
|
1021
|
+
...updated[updated.length - 1],
|
|
1022
|
+
cache_control: { type: 'ephemeral' }
|
|
1023
|
+
};
|
|
1024
|
+
return { ...m, content: updated };
|
|
1025
|
+
}
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
return m;
|
|
1029
|
+
});
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
parseStreamChunk(parsed, state) {
|
|
1033
|
+
const result = {
|
|
1034
|
+
textChunk: '',
|
|
1035
|
+
reasoningChunk: '',
|
|
1036
|
+
toolCallDeltas: [],
|
|
1037
|
+
usage: null,
|
|
1038
|
+
cachedTokens: 0,
|
|
1039
|
+
cacheCreationTokens: 0
|
|
1040
|
+
};
|
|
1041
|
+
|
|
1042
|
+
if (parsed.type === 'message_start' && parsed.message?.usage) {
|
|
1043
|
+
result.usage = parsed.message.usage;
|
|
1044
|
+
if (parsed.message.usage.cache_read_input_tokens) {
|
|
1045
|
+
result.cachedTokens = parsed.message.usage.cache_read_input_tokens;
|
|
1046
|
+
}
|
|
1047
|
+
if (parsed.message.usage.cache_creation_input_tokens) {
|
|
1048
|
+
result.cacheCreationTokens = parsed.message.usage.cache_creation_input_tokens;
|
|
1049
|
+
}
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
if (parsed.type === 'message_delta' && parsed.usage) {
|
|
1053
|
+
result.usage = parsed.usage;
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
if (parsed.type === 'content_block_delta') {
|
|
1057
|
+
if (parsed.delta?.type === 'thinking_delta' && parsed.delta?.thinking) {
|
|
1058
|
+
result.reasoningChunk = parsed.delta.thinking;
|
|
1059
|
+
} else if (parsed.delta?.type === 'text_delta' && parsed.delta?.text) {
|
|
1060
|
+
result.textChunk = parsed.delta.text;
|
|
1061
|
+
}
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
// Fallback a formato choices por si OpenRouter sirve Claude en formato OpenAI
|
|
1065
|
+
if (parsed.choices?.[0] || parsed.usage) {
|
|
1066
|
+
const baseRes = super.parseStreamChunk(parsed, state);
|
|
1067
|
+
if (baseRes.textChunk) result.textChunk = baseRes.textChunk;
|
|
1068
|
+
if (baseRes.reasoningChunk) result.reasoningChunk = baseRes.reasoningChunk;
|
|
1069
|
+
if (baseRes.toolCallDeltas.length > 0) result.toolCallDeltas = baseRes.toolCallDeltas;
|
|
1070
|
+
if (baseRes.cachedTokens > 0) result.cachedTokens = baseRes.cachedTokens;
|
|
1071
|
+
if (baseRes.cacheCreationTokens > 0) result.cacheCreationTokens = baseRes.cacheCreationTokens;
|
|
1072
|
+
if (baseRes.usage && !result.usage) result.usage = baseRes.usage;
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
return result;
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
getModelEndpoints(cleanUrl) {
|
|
1079
|
+
const v1Url = cleanUrl.endsWith('/v1') ? cleanUrl : `${cleanUrl}/v1`;
|
|
1080
|
+
return [`${v1Url}/models`];
|
|
1081
|
+
}
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
/**
|
|
1085
|
+
* Adaptador para Google Gemini (Endpoint OpenAI compatible).
|
|
1086
|
+
*/
|
|
1087
|
+
class GeminiProviderAdapter extends BaseProviderAdapter {
|
|
1088
|
+
constructor() {
|
|
1089
|
+
super({
|
|
1090
|
+
id: 'gemini',
|
|
1091
|
+
label: 'Google Gemini',
|
|
1092
|
+
connection: { endpoint: 'https://generativelanguage.googleapis.com/v1beta/openai' },
|
|
1093
|
+
description: 'Google Gemini (OpenAI compatible endpoint)',
|
|
1094
|
+
reasoningLevels: ['none'],
|
|
1095
|
+
capabilities: {
|
|
1096
|
+
streaming: true,
|
|
1097
|
+
vision: true,
|
|
1098
|
+
tools: true,
|
|
1099
|
+
reasoning: false,
|
|
1100
|
+
jsonMode: true,
|
|
1101
|
+
promptCaching: true,
|
|
1102
|
+
embeddings: true,
|
|
1103
|
+
modelListing: true
|
|
1104
|
+
}
|
|
1105
|
+
});
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
/**
|
|
1109
|
+
* En el endpoint OpenAI de Gemini (/v1beta/openai), la caché es implícita en servidor.
|
|
1110
|
+
* No se inyecta stream_options para no causar rechazos con stream: false o endpoints estrictos.
|
|
1111
|
+
*/
|
|
1112
|
+
applyContextCache(payload, options = {}) {
|
|
1113
|
+
// Gemini maneja context caching automáticamente a nivel de servidor.
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
normalizeEndpoint(rawUrl) {
|
|
1117
|
+
let url = (rawUrl || 'https://generativelanguage.googleapis.com/v1beta/openai').trim();
|
|
1118
|
+
if (url.endsWith('/')) url = url.slice(0, -1);
|
|
1119
|
+
if (url.endsWith('/chat/completions')) return url;
|
|
1120
|
+
if (url.endsWith('/v1')) return `${url}/chat/completions`;
|
|
1121
|
+
return `${url}/chat/completions`;
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
getModelEndpoints(cleanUrl) {
|
|
1125
|
+
const v1Url = cleanUrl.endsWith('/v1') ? cleanUrl : `${cleanUrl}/v1`;
|
|
1126
|
+
return [`${v1Url}/models`, `${cleanUrl}/models`];
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
parseModelsResponse(data) {
|
|
1130
|
+
if (!data) return [];
|
|
1131
|
+
let list = [];
|
|
1132
|
+
if (Array.isArray(data.data)) {
|
|
1133
|
+
list = data.data;
|
|
1134
|
+
} else if (Array.isArray(data.models)) {
|
|
1135
|
+
list = data.models;
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
return list
|
|
1139
|
+
.map(m => {
|
|
1140
|
+
let id = m.id || m.name || '';
|
|
1141
|
+
if (id.startsWith('models/')) id = id.substring(7);
|
|
1142
|
+
return {
|
|
1143
|
+
id: id,
|
|
1144
|
+
name: m.displayName || id,
|
|
1145
|
+
description: m.description || ''
|
|
1146
|
+
};
|
|
1147
|
+
})
|
|
1148
|
+
.filter(m => m.id && (m.id.includes('gemini') || m.id.includes('gemma')));
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
formatMessages(messages, capabilities) {
|
|
1152
|
+
const formatted = [];
|
|
1153
|
+
(messages || []).forEach(m => {
|
|
1154
|
+
if (!m || !m.role) return;
|
|
1155
|
+
|
|
1156
|
+
if (m.role === 'assistant' && Array.isArray(m.tool_calls) && m.tool_calls.length > 0) {
|
|
1157
|
+
const cleanToolCalls = m.tool_calls.map(tc => {
|
|
1158
|
+
const rawArgs = typeof tc.function?.arguments === 'string'
|
|
1159
|
+
? tc.function.arguments
|
|
1160
|
+
: JSON.stringify(tc.function?.arguments || {});
|
|
1161
|
+
|
|
1162
|
+
let safeArgs = rawArgs;
|
|
1163
|
+
try {
|
|
1164
|
+
JSON.parse(rawArgs);
|
|
1165
|
+
} catch (e) {
|
|
1166
|
+
const matches = rawArgs.match(/\{[^{}]*"[a-zA-Z0-9_]+"[^{}]*\}/g);
|
|
1167
|
+
if (matches && matches.length > 0) {
|
|
1168
|
+
try {
|
|
1169
|
+
JSON.parse(matches[0]);
|
|
1170
|
+
safeArgs = matches[0];
|
|
1171
|
+
} catch (e2) {
|
|
1172
|
+
safeArgs = '{}';
|
|
1173
|
+
}
|
|
1174
|
+
} else {
|
|
1175
|
+
safeArgs = '{}';
|
|
1176
|
+
}
|
|
1177
|
+
}
|
|
1178
|
+
|
|
1179
|
+
const out = {
|
|
1180
|
+
id: tc.id || `call_${Date.now()}`,
|
|
1181
|
+
type: 'function',
|
|
1182
|
+
function: {
|
|
1183
|
+
name: tc.function?.name || tc.name || '',
|
|
1184
|
+
arguments: safeArgs
|
|
1185
|
+
}
|
|
1186
|
+
};
|
|
1187
|
+
|
|
1188
|
+
const sig = tc.thought_signature || tc.extra_content?.google?.thought_signature;
|
|
1189
|
+
if (sig) {
|
|
1190
|
+
out.extra_content = { google: { thought_signature: sig } };
|
|
1191
|
+
} else if (tc.extra_content) {
|
|
1192
|
+
out.extra_content = tc.extra_content;
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
return out;
|
|
1196
|
+
});
|
|
1197
|
+
|
|
1198
|
+
// Regla Gemini: Un turno assistant con tool_calls NUNCA puede ir inmediatamente después de system
|
|
1199
|
+
const prevMsg = formatted.length > 0 ? formatted[formatted.length - 1] : null;
|
|
1200
|
+
if (!prevMsg || prevMsg.role === 'system') {
|
|
1201
|
+
formatted.push({ role: 'user', content: 'Continue' });
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
formatted.push({
|
|
1205
|
+
role: 'assistant',
|
|
1206
|
+
content: m.content || null,
|
|
1207
|
+
tool_calls: cleanToolCalls
|
|
1208
|
+
});
|
|
1209
|
+
} else if (m.role === 'tool') {
|
|
1210
|
+
const toolCallId = m.tool_call_id || `call_${Date.now()}`;
|
|
1211
|
+
const toolName = m.name || 'tool';
|
|
1212
|
+
const toolContent = serializeContent(m.content);
|
|
1213
|
+
|
|
1214
|
+
// Validar que el mensaje previo sea un assistant con la llamada correspondiente
|
|
1215
|
+
let prevMsg = formatted.length > 0 ? formatted[formatted.length - 1] : null;
|
|
1216
|
+
const hasMatchingToolCall = prevMsg && prevMsg.role === 'assistant' && Array.isArray(prevMsg.tool_calls) &&
|
|
1217
|
+
prevMsg.tool_calls.some(tc => tc.id === toolCallId || (tc.function && tc.function.name === toolName));
|
|
1218
|
+
|
|
1219
|
+
if (!hasMatchingToolCall) {
|
|
1220
|
+
// Si el mensaje anterior a este asistente autogenerado es system, insertar user primero
|
|
1221
|
+
if (!prevMsg || prevMsg.role === 'system') {
|
|
1222
|
+
formatted.push({ role: 'user', content: 'Continue' });
|
|
1223
|
+
}
|
|
1224
|
+
formatted.push({
|
|
1225
|
+
role: 'assistant',
|
|
1226
|
+
content: null,
|
|
1227
|
+
tool_calls: [{
|
|
1228
|
+
id: toolCallId,
|
|
1229
|
+
type: 'function',
|
|
1230
|
+
function: {
|
|
1231
|
+
name: toolName,
|
|
1232
|
+
arguments: '{}'
|
|
1233
|
+
}
|
|
1234
|
+
}]
|
|
1235
|
+
});
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1238
|
+
formatted.push({
|
|
1239
|
+
role: 'tool',
|
|
1240
|
+
tool_call_id: toolCallId,
|
|
1241
|
+
name: toolName,
|
|
1242
|
+
content: toolContent
|
|
1243
|
+
});
|
|
1244
|
+
} else if (m.role === 'assistant') {
|
|
1245
|
+
// Si el mensaje es assistant (texto) y va inmediatamente después de system, insertar user antes
|
|
1246
|
+
const prevMsg = formatted.length > 0 ? formatted[formatted.length - 1] : null;
|
|
1247
|
+
if (!prevMsg || prevMsg.role === 'system') {
|
|
1248
|
+
formatted.push({ role: 'user', content: 'Continue' });
|
|
1249
|
+
}
|
|
1250
|
+
} else if (m.role === 'user') {
|
|
1251
|
+
// Regla Gemini: Un turno 'user' NUNCA puede ir inmediatamente después de un turno 'tool'
|
|
1252
|
+
const prevMsg = formatted.length > 0 ? formatted[formatted.length - 1] : null;
|
|
1253
|
+
if (prevMsg && prevMsg.role === 'tool') {
|
|
1254
|
+
formatted.push({ role: 'assistant', content: 'Tool information received.' });
|
|
1255
|
+
}
|
|
1256
|
+
formatted.push(m);
|
|
1257
|
+
} else {
|
|
1258
|
+
formatted.push(m);
|
|
1259
|
+
}
|
|
1260
|
+
});
|
|
1261
|
+
return formatted;
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1264
|
+
buildPayload(params) {
|
|
1265
|
+
const payload = super.buildPayload(params);
|
|
1266
|
+
// El endpoint OpenAI de Gemini requiere el nombre del modelo sin el prefijo "models/"
|
|
1267
|
+
if (payload.model && payload.model.startsWith('models/')) {
|
|
1268
|
+
payload.model = payload.model.substring(7);
|
|
1269
|
+
}
|
|
1270
|
+
return payload;
|
|
1271
|
+
}
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
/**
|
|
1275
|
+
* Adaptador para Ollama (/api/chat, /api/tags).
|
|
1276
|
+
*/
|
|
1277
|
+
class OllamaProviderAdapter extends BaseProviderAdapter {
|
|
1278
|
+
constructor() {
|
|
1279
|
+
super({
|
|
1280
|
+
id: 'ollama',
|
|
1281
|
+
label: 'Ollama',
|
|
1282
|
+
connection: { endpoint: 'http://localhost:11434' },
|
|
1283
|
+
description: 'Estándar Ollama (reasoning_effort: none, low, medium, high, xhigh)',
|
|
1284
|
+
reasoningLevels: ['none', 'low', 'medium', 'high', 'xhigh'],
|
|
1285
|
+
capabilities: {
|
|
1286
|
+
streaming: true,
|
|
1287
|
+
vision: true,
|
|
1288
|
+
tools: true,
|
|
1289
|
+
reasoning: true,
|
|
1290
|
+
jsonMode: true,
|
|
1291
|
+
promptCaching: false,
|
|
1292
|
+
embeddings: true,
|
|
1293
|
+
modelListing: true
|
|
1294
|
+
}
|
|
1295
|
+
});
|
|
1296
|
+
}
|
|
1297
|
+
|
|
1298
|
+
normalizeEndpoint(rawUrl) {
|
|
1299
|
+
let url = (rawUrl || 'http://localhost:11434').trim();
|
|
1300
|
+
if (url.endsWith('/')) url = url.slice(0, -1);
|
|
1301
|
+
if (url.endsWith('/api/chat') || url.endsWith('/chat/completions')) return url;
|
|
1302
|
+
if (url.endsWith('/v1')) return `${url}/chat/completions`;
|
|
1303
|
+
return `${url}/v1/chat/completions`;
|
|
1304
|
+
}
|
|
1305
|
+
|
|
1306
|
+
getModelEndpoints(cleanUrl) {
|
|
1307
|
+
const baseWithoutV1 = cleanUrl.replace(/\/v1$/, '');
|
|
1308
|
+
const v1Url = cleanUrl.endsWith('/v1') ? cleanUrl : `${cleanUrl}/v1`;
|
|
1309
|
+
return [`${baseWithoutV1}/api/tags`, `${v1Url}/models`];
|
|
1310
|
+
}
|
|
1311
|
+
|
|
1312
|
+
parseModelsResponse(data) {
|
|
1313
|
+
if (data && Array.isArray(data.models)) {
|
|
1314
|
+
return data.models.map(item => {
|
|
1315
|
+
if (typeof item === 'string') return { id: item, name: item };
|
|
1316
|
+
return {
|
|
1317
|
+
id: item.name || item.model || item.id || '',
|
|
1318
|
+
name: item.name || item.model || item.id || '',
|
|
1319
|
+
details: item
|
|
1320
|
+
};
|
|
1321
|
+
}).filter(m => !!m.id);
|
|
1322
|
+
}
|
|
1323
|
+
return super.parseModelsResponse(data);
|
|
1324
|
+
}
|
|
1325
|
+
}
|
|
1326
|
+
|
|
1327
|
+
/**
|
|
1328
|
+
* Adaptador para OpenRouter (https://openrouter.ai/api/v1).
|
|
1329
|
+
*/
|
|
1330
|
+
class OpenRouterProviderAdapter extends BaseProviderAdapter {
|
|
1331
|
+
constructor() {
|
|
1332
|
+
super({
|
|
1333
|
+
id: 'openrouter',
|
|
1334
|
+
label: 'OpenRouter',
|
|
1335
|
+
connection: { endpoint: 'https://openrouter.ai/api/v1' },
|
|
1336
|
+
description: 'Estándar OpenRouter (reasoning.effort: none, low, medium, high, xhigh)',
|
|
1337
|
+
reasoningLevels: ['none', 'low', 'medium', 'high', 'xhigh'],
|
|
1338
|
+
capabilities: {
|
|
1339
|
+
streaming: true,
|
|
1340
|
+
vision: true,
|
|
1341
|
+
tools: true,
|
|
1342
|
+
reasoning: true,
|
|
1343
|
+
jsonMode: true,
|
|
1344
|
+
promptCaching: true,
|
|
1345
|
+
embeddings: false,
|
|
1346
|
+
modelListing: true
|
|
1347
|
+
}
|
|
1348
|
+
});
|
|
1349
|
+
}
|
|
1350
|
+
|
|
1351
|
+
normalizeEndpoint(rawUrl) {
|
|
1352
|
+
let url = (rawUrl || 'https://openrouter.ai/api/v1').trim();
|
|
1353
|
+
if (url.endsWith('/')) url = url.slice(0, -1);
|
|
1354
|
+
if (url.endsWith('/chat/completions')) return url;
|
|
1355
|
+
if (url.endsWith('/v1')) return `${url}/chat/completions`;
|
|
1356
|
+
return `${url}/v1/chat/completions`;
|
|
1357
|
+
}
|
|
1358
|
+
|
|
1359
|
+
applyReasoning(payload, effortLevel) {
|
|
1360
|
+
let effort = String(effortLevel || 'none').toLowerCase().trim();
|
|
1361
|
+
if (effort === 'off') effort = 'none';
|
|
1362
|
+
payload.reasoning = { effort: effort };
|
|
1363
|
+
payload.reasoning_effort = effort;
|
|
1364
|
+
}
|
|
1365
|
+
|
|
1366
|
+
applyContextCache(payload, options = {}) {
|
|
1367
|
+
super.applyContextCache(payload, options);
|
|
1368
|
+
// Inyectar cache_control con límite estricto de máximo 4 puntos efímeros (política OpenRouter / Anthropic)
|
|
1369
|
+
const { toolsList = [], messages = [] } = options;
|
|
1370
|
+
let usedBreakpoints = 0;
|
|
1371
|
+
const MAX_BREAKPOINTS = 4;
|
|
1372
|
+
|
|
1373
|
+
// 1. Marcar la última herramienta si hay tools (1 punto)
|
|
1374
|
+
if (toolsList.length > 0 && usedBreakpoints < MAX_BREAKPOINTS) {
|
|
1375
|
+
if (!toolsList[toolsList.length - 1].cache_control) {
|
|
1376
|
+
toolsList[toolsList.length - 1].cache_control = { type: 'ephemeral' };
|
|
1377
|
+
}
|
|
1378
|
+
usedBreakpoints++;
|
|
1379
|
+
}
|
|
1380
|
+
|
|
1381
|
+
// 2. Identificar el último system prompt consolidado (1 punto)
|
|
1382
|
+
let lastSystemIdx = -1;
|
|
1383
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
1384
|
+
if (messages[i].role === 'system') {
|
|
1385
|
+
lastSystemIdx = i;
|
|
1386
|
+
break;
|
|
1387
|
+
}
|
|
1388
|
+
}
|
|
1389
|
+
|
|
1390
|
+
// 3. Identificar el último turno procesable (user o tool) (1 punto)
|
|
1391
|
+
let lastProcessableIdx = -1;
|
|
1392
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
1393
|
+
if (messages[i].role === 'user' || messages[i].role === 'tool') {
|
|
1394
|
+
lastProcessableIdx = i;
|
|
1395
|
+
break;
|
|
1396
|
+
}
|
|
1397
|
+
}
|
|
1398
|
+
|
|
1399
|
+
function attachCacheControl(content) {
|
|
1400
|
+
if (typeof content === 'string') {
|
|
1401
|
+
return [{ type: 'text', text: content, cache_control: { type: 'ephemeral' } }];
|
|
1402
|
+
} else if (Array.isArray(content) && content.length > 0) {
|
|
1403
|
+
const updated = [...content];
|
|
1404
|
+
const lastItem = updated[updated.length - 1];
|
|
1405
|
+
updated[updated.length - 1] = {
|
|
1406
|
+
...lastItem,
|
|
1407
|
+
cache_control: { type: 'ephemeral' }
|
|
1408
|
+
};
|
|
1409
|
+
return updated;
|
|
1410
|
+
}
|
|
1411
|
+
return content;
|
|
1412
|
+
}
|
|
1413
|
+
|
|
1414
|
+
payload.messages = messages.map((m, idx) => {
|
|
1415
|
+
if (idx === lastSystemIdx && usedBreakpoints < MAX_BREAKPOINTS) {
|
|
1416
|
+
usedBreakpoints++;
|
|
1417
|
+
return { ...m, content: attachCacheControl(m.content) };
|
|
1418
|
+
}
|
|
1419
|
+
|
|
1420
|
+
if (idx === lastProcessableIdx && usedBreakpoints < MAX_BREAKPOINTS) {
|
|
1421
|
+
usedBreakpoints++;
|
|
1422
|
+
return { ...m, content: attachCacheControl(m.content) };
|
|
1423
|
+
}
|
|
1424
|
+
|
|
1425
|
+
return m;
|
|
1426
|
+
});
|
|
1427
|
+
}
|
|
1428
|
+
|
|
1429
|
+
getModelEndpoints(cleanUrl) {
|
|
1430
|
+
const v1Url = cleanUrl.endsWith('/v1') ? cleanUrl : `${cleanUrl}/v1`;
|
|
1431
|
+
return [`${v1Url}/models`];
|
|
1432
|
+
}
|
|
1433
|
+
}
|
|
1434
|
+
|
|
1435
|
+
/**
|
|
1436
|
+
* Registro central de adaptadores de proveedor (ProviderRegistry).
|
|
1437
|
+
*/
|
|
1438
|
+
class MirrorProviderAdapter extends BaseProviderAdapter {
|
|
1439
|
+
constructor() {
|
|
1440
|
+
super({ id: 'mirror', label: 'Espejo', connection: { endpoint: 'mirror://local', endpointReadOnly: true, credentials: false }, capabilities: { modelListing: false, embeddings: false } });
|
|
1441
|
+
}
|
|
1442
|
+
|
|
1443
|
+
normalizeEndpoint() { return 'mirror://local'; }
|
|
1444
|
+
|
|
1445
|
+
async inspect() {
|
|
1446
|
+
return { success: false, error: 'Mirror is a local request preview and has no server to inspect.' };
|
|
1447
|
+
}
|
|
1448
|
+
}
|
|
1449
|
+
|
|
1450
|
+
class ProviderRegistry {
|
|
1451
|
+
constructor() {
|
|
1452
|
+
this.adapters = new Map();
|
|
1453
|
+
this.defaultAdapter = new BaseProviderAdapter();
|
|
1454
|
+
|
|
1455
|
+
// Registro inicial de adaptadores oficiales
|
|
1456
|
+
this.register(new BaseProviderAdapter({ id: 'openai', label: 'OpenAI / LM Studio', connection: { endpoint: 'http://localhost:1234/v1', knownEndpoints: ['https://api.openai.com/v1'] } }));
|
|
1457
|
+
this.register(new ClaudeProviderAdapter());
|
|
1458
|
+
this.register(new GeminiProviderAdapter());
|
|
1459
|
+
this.register(new OllamaProviderAdapter());
|
|
1460
|
+
this.register(new OpenRouterProviderAdapter());
|
|
1461
|
+
this.register(new BaseProviderAdapter({ id: 'custom', label: 'Personalizado' }));
|
|
1462
|
+
this.register(new MirrorProviderAdapter());
|
|
1463
|
+
}
|
|
1464
|
+
|
|
1465
|
+
/**
|
|
1466
|
+
* Registra un adaptador de proveedor.
|
|
1467
|
+
*/
|
|
1468
|
+
register(adapter) {
|
|
1469
|
+
if (adapter && adapter.id) {
|
|
1470
|
+
this.adapters.set(adapter.id.toLowerCase(), adapter);
|
|
1471
|
+
}
|
|
1472
|
+
}
|
|
1473
|
+
|
|
1474
|
+
/**
|
|
1475
|
+
* Obtiene un adaptador por su ID.
|
|
1476
|
+
*/
|
|
1477
|
+
get(id) {
|
|
1478
|
+
if (!id) return this.defaultAdapter;
|
|
1479
|
+
return this.adapters.get(String(id).toLowerCase()) || this.defaultAdapter;
|
|
1480
|
+
}
|
|
1481
|
+
|
|
1482
|
+
/**
|
|
1483
|
+
* Detecta el proveedor adecuado a partir de la URL del servidor o tipo explícito.
|
|
1484
|
+
*/
|
|
1485
|
+
detect(rawUrl, explicitType) {
|
|
1486
|
+
if (explicitType && explicitType !== 'auto') {
|
|
1487
|
+
return String(explicitType).toLowerCase();
|
|
1488
|
+
}
|
|
1489
|
+
const url = (rawUrl || '').toLowerCase().trim();
|
|
1490
|
+
if (url.includes('11434') || url.includes('ollama')) return 'ollama';
|
|
1491
|
+
if (url.includes('openrouter.ai')) return 'openrouter';
|
|
1492
|
+
if (url.includes('anthropic.com')) return 'claude';
|
|
1493
|
+
if (url.includes('googleapis.com') || url.includes('gemini')) return 'gemini';
|
|
1494
|
+
return 'openai';
|
|
1495
|
+
}
|
|
1496
|
+
|
|
1497
|
+
/**
|
|
1498
|
+
* Resuelve y devuelve la instancia del adaptador para una URL y tipo dados.
|
|
1499
|
+
*/
|
|
1500
|
+
resolve(rawUrl, explicitType) {
|
|
1501
|
+
const type = this.detect(rawUrl, explicitType);
|
|
1502
|
+
return this.get(type);
|
|
1503
|
+
}
|
|
1504
|
+
|
|
1505
|
+
/**
|
|
1506
|
+
* Obtiene las capacidades de un proveedor o modelo dado.
|
|
1507
|
+
*/
|
|
1508
|
+
getCapabilities(rawUrlOrType, model, explicitType) {
|
|
1509
|
+
const adapter = this.resolve(rawUrlOrType, explicitType);
|
|
1510
|
+
return adapter.getCapabilities(model);
|
|
1511
|
+
}
|
|
1512
|
+
|
|
1513
|
+
/**
|
|
1514
|
+
* Obtiene todos los modos de razonamiento registrados para la UI.
|
|
1515
|
+
*/
|
|
1516
|
+
getReasoningModes() {
|
|
1517
|
+
const modes = {};
|
|
1518
|
+
for (const [id, adapter] of this.adapters.entries()) {
|
|
1519
|
+
modes[id] = adapter.getReasoningConfig();
|
|
1520
|
+
}
|
|
1521
|
+
return modes;
|
|
1522
|
+
}
|
|
1523
|
+
|
|
1524
|
+
getConnectionEndpoints() {
|
|
1525
|
+
return Array.from(this.adapters.values())
|
|
1526
|
+
.flatMap(adapter => {
|
|
1527
|
+
const connection = adapter.getConnectionConfig?.() || {};
|
|
1528
|
+
return [connection.endpoint, ...(connection.knownEndpoints || [])];
|
|
1529
|
+
})
|
|
1530
|
+
.filter(Boolean);
|
|
1531
|
+
}
|
|
1532
|
+
|
|
1533
|
+
/**
|
|
1534
|
+
* Inspecciona y diagnostica el endpoint del proveedor delegando en el adaptador correspondiente.
|
|
1535
|
+
*/
|
|
1536
|
+
async inspect(rawUrl, apiKey, model, explicitType, options = {}) {
|
|
1537
|
+
const adapter = this.resolve(rawUrl, explicitType);
|
|
1538
|
+
return adapter.inspect({
|
|
1539
|
+
...options,
|
|
1540
|
+
apiUrl: rawUrl,
|
|
1541
|
+
apiKey: apiKey,
|
|
1542
|
+
model: model
|
|
1543
|
+
});
|
|
1544
|
+
}
|
|
1545
|
+
}
|
|
1546
|
+
|
|
1547
|
+
const registry = new ProviderRegistry();
|
|
1548
|
+
|
|
1549
|
+
return {
|
|
1550
|
+
DEFAULT_CAPABILITIES,
|
|
1551
|
+
BaseProviderAdapter,
|
|
1552
|
+
MirrorProviderAdapter,
|
|
1553
|
+
ClaudeProviderAdapter,
|
|
1554
|
+
GeminiProviderAdapter,
|
|
1555
|
+
OllamaProviderAdapter,
|
|
1556
|
+
OpenRouterProviderAdapter,
|
|
1557
|
+
ProviderRegistry,
|
|
1558
|
+
registry
|
|
1559
|
+
};
|
|
1560
|
+
});
|