vipcode 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,119 @@
1
+ import { spawn } from "node:child_process";
2
+ import path from "node:path";
3
+ import { readMarkdownDir } from "./frontmatter.js";
4
+ import { GLOBAL_COMMANDS_DIR } from "./paths.js";
5
+ import { truncate } from "./tools.js";
6
+ const INIT_TEMPLATE = `Analiza este proyecto y crea (o mejora, si ya existe) el fichero AGENTS.md en la raíz, con las instrucciones que necesitaría otro agente de programación para trabajar aquí sin perder tiempo.
7
+
8
+ Incluye solo lo que sea útil y concreto:
9
+ - Para qué sirve el proyecto y su estructura de carpetas principal.
10
+ - Cómo instalar dependencias, compilar, ejecutar, pasar los tests y el linter (comandos exactos, incluido cómo ejecutar un solo test).
11
+ - Convenciones de código que se ven en el proyecto (estilo, nombres, idioma de los comentarios, cómo se organizan los ficheros).
12
+ - Cosas que no son obvias y que conviene saber (variables de entorno, servicios externos, trampas conocidas).
13
+
14
+ Antes de escribir, explora el proyecto (README, ficheros de configuración, package.json/pyproject/Makefile…, algunos ficheros de código).
15
+ Si ya hay reglas en CLAUDE.md, .cursorrules, .cursor/rules o .github/copilot-instructions.md, incorpóralas.
16
+ Escribe AGENTS.md en el idioma principal del proyecto (si dudas, en español), conciso: unas 20-60 líneas.
17
+ $ARGUMENTS`;
18
+ const REVIEW_TEMPLATE = `Revisa los cambios sin confirmar de este repositorio (usa \`git status\`, \`git diff\` y \`git diff --staged\`; si no hay cambios, revisa el último commit con \`git show\`).
19
+
20
+ Busca, por orden de importancia: errores que romperían algo, casos límite no tratados, problemas de seguridad, código que no sigue el estilo del proyecto y cosas que se podrían simplificar.
21
+ Para cada problema di el fichero y la línea, por qué es un problema y cómo lo arreglarías. No cambies nada: solo informa.
22
+ $ARGUMENTS`;
23
+ const BUILTIN = [
24
+ { name: "init", description: "Crea o mejora AGENTS.md con las instrucciones del proyecto", template: INIT_TEMPLATE, subtask: false, builtIn: true },
25
+ { name: "review", description: "Revisa los cambios sin confirmar (no modifica nada)", template: REVIEW_TEMPLATE, agent: "plan", subtask: false, builtIn: true },
26
+ ];
27
+ // Comandos integrados + ~/.vipcode/commands + .vipcode/commands y .opencode/command(s) del proyecto + "commands" del config
28
+ export function loadCommands(root, config) {
29
+ const commands = new Map();
30
+ for (const command of BUILTIN) {
31
+ commands.set(command.name, { ...command });
32
+ }
33
+ const dirs = [
34
+ GLOBAL_COMMANDS_DIR,
35
+ path.join(root, ".opencode", "command"),
36
+ path.join(root, ".opencode", "commands"),
37
+ path.join(root, ".vipcode", "commands"),
38
+ ];
39
+ for (const entry of readMarkdownDir(dirs)) {
40
+ const data = entry.data;
41
+ commands.set(entry.name, {
42
+ name: entry.name,
43
+ description: String(data.description || `Comando ${entry.name}`),
44
+ template: entry.body,
45
+ agent: data.agent ? String(data.agent) : undefined,
46
+ model: data.model ? String(data.model) : undefined,
47
+ subtask: Boolean(data.subtask),
48
+ builtIn: false,
49
+ source: entry.file,
50
+ });
51
+ }
52
+ for (const [name, input] of Object.entries(config.commands || {})) {
53
+ if (!input || typeof input.template !== "string") {
54
+ continue;
55
+ }
56
+ commands.set(name, {
57
+ name,
58
+ description: input.description || `Comando ${name}`,
59
+ template: input.template,
60
+ agent: input.agent,
61
+ model: input.model,
62
+ subtask: Boolean(input.subtask),
63
+ builtIn: false,
64
+ });
65
+ }
66
+ return [...commands.values()];
67
+ }
68
+ // Separa los argumentos respetando las comillas: a "b c" d → [a, b c, d]
69
+ export function splitArgs(text) {
70
+ const result = [];
71
+ const pattern = /"([^"]*)"|'([^']*)'|(\S+)/g;
72
+ let match;
73
+ while ((match = pattern.exec(text)) !== null) {
74
+ result.push(match[1] ?? match[2] ?? match[3]);
75
+ }
76
+ return result;
77
+ }
78
+ function runShell(command, cwd) {
79
+ return new Promise((resolve) => {
80
+ let shell = "bash";
81
+ let args = ["-lc", command];
82
+ if (process.platform === "win32") {
83
+ shell = "cmd.exe";
84
+ args = ["/c", command];
85
+ }
86
+ const child = spawn(shell, args, { cwd, stdio: ["ignore", "pipe", "pipe"], env: { ...process.env, GIT_PAGER: "cat", PAGER: "cat" } });
87
+ let output = "";
88
+ const timer = setTimeout(() => child.kill("SIGTERM"), 30000);
89
+ child.stdout.on("data", (d) => (output += d.toString()));
90
+ child.stderr.on("data", (d) => (output += d.toString()));
91
+ child.on("error", (err) => {
92
+ clearTimeout(timer);
93
+ resolve(`(no se pudo ejecutar: ${err.message})`);
94
+ });
95
+ child.on("close", () => {
96
+ clearTimeout(timer);
97
+ resolve(truncate(output.trim(), 10000));
98
+ });
99
+ });
100
+ }
101
+ // Rellena la plantilla: $ARGUMENTS, $1…$9 y !`comando` (se sustituye por su salida)
102
+ export async function expandCommand(command, args, root) {
103
+ const parts = splitArgs(args);
104
+ let text = command.template;
105
+ const usesArgs = /\$ARGUMENTS|\$\d/.test(text);
106
+ // Con función para que un "$&" dentro de los argumentos no se interprete
107
+ text = text.replace(/\$ARGUMENTS/g, () => args);
108
+ text = text.replace(/\$(\d)/g, (_match, n) => parts[Number(n) - 1] ?? "");
109
+ if (!usesArgs && args.trim() !== "") {
110
+ text += `\n\n${args}`;
111
+ }
112
+ const shellPattern = /!`([^`]+)`/g;
113
+ const commands = [...text.matchAll(shellPattern)];
114
+ for (const match of commands) {
115
+ const output = await runShell(match[1], root);
116
+ text = text.replace(match[0], () => output);
117
+ }
118
+ return text.trim();
119
+ }
package/dist/config.js ADDED
@@ -0,0 +1,225 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { CONFIG_PATH, HOME_DIR } from "./paths.js";
4
+ export { CONFIG_PATH, HOME_DIR, SESSIONS_DIR, STATE_PATH } from "./paths.js";
5
+ export const DEFAULT_PERMISSION = {
6
+ edit: "ask",
7
+ bash: {
8
+ "*": "ask",
9
+ "git status*": "allow",
10
+ "git diff*": "allow",
11
+ "git log*": "allow",
12
+ "git show*": "allow",
13
+ "ls*": "allow",
14
+ pwd: "allow",
15
+ },
16
+ webfetch: "ask",
17
+ mcp: "ask",
18
+ };
19
+ export const DEFAULT_CONFIG = {
20
+ providers: [],
21
+ disabledProviders: [],
22
+ enabledProviders: [],
23
+ order: [],
24
+ model: "",
25
+ maxSteps: 50,
26
+ autoCompactTokens: 90000,
27
+ bashTimeoutMs: 120000,
28
+ maxWaitMs: 90000,
29
+ permission: DEFAULT_PERMISSION,
30
+ mcp: {},
31
+ lsp: {},
32
+ agents: {},
33
+ commands: {},
34
+ instructions: [],
35
+ snapshot: true,
36
+ autoUpdateModels: true,
37
+ checkUpdates: true,
38
+ keyless: true,
39
+ ranking: { enabled: true, metric: "coding", anonymousLast: false },
40
+ skills: { enabled: true, paths: [], disabled: [], external: true },
41
+ showReasoning: true,
42
+ };
43
+ // Proveedor de internet que se puede usar sin clave (los locales no cuentan)
44
+ export function isAnonymous(provider) {
45
+ return Boolean(provider.noKey && !provider.local);
46
+ }
47
+ // Lo que se escribe la primera vez en ~/.vipcode/config.json para que sea fácil de editar
48
+ const TEMPLATE = {
49
+ model: "",
50
+ order: [],
51
+ disabledProviders: [],
52
+ providers: [],
53
+ permission: DEFAULT_PERMISSION,
54
+ mcp: {},
55
+ lsp: {},
56
+ agents: {},
57
+ commands: {},
58
+ instructions: [],
59
+ maxSteps: DEFAULT_CONFIG.maxSteps,
60
+ autoCompactTokens: DEFAULT_CONFIG.autoCompactTokens,
61
+ bashTimeoutMs: DEFAULT_CONFIG.bashTimeoutMs,
62
+ maxWaitMs: DEFAULT_CONFIG.maxWaitMs,
63
+ snapshot: true,
64
+ autoUpdateModels: true,
65
+ keyless: true,
66
+ ranking: DEFAULT_CONFIG.ranking,
67
+ showReasoning: true,
68
+ };
69
+ // Avisos de la carga del config para enseñarlos al arrancar
70
+ export const configNotices = [];
71
+ function isObject(value) {
72
+ return typeof value === "object" && value !== null && !Array.isArray(value);
73
+ }
74
+ // Mezcla objetos en profundidad; los arrays y los valores simples se sustituyen
75
+ export function deepMerge(base, override) {
76
+ if (!isObject(base) || !isObject(override)) {
77
+ return override;
78
+ }
79
+ const result = { ...base };
80
+ for (const [key, value] of Object.entries(override)) {
81
+ if (value === undefined) {
82
+ continue;
83
+ }
84
+ if (isObject(value) && isObject(result[key])) {
85
+ result[key] = deepMerge(result[key], value);
86
+ }
87
+ else {
88
+ result[key] = value;
89
+ }
90
+ }
91
+ return result;
92
+ }
93
+ function mergeModels(base, override) {
94
+ const result = base.map((model) => ({ ...model }));
95
+ for (const model of override) {
96
+ const index = result.findIndex((existing) => existing.id === model.id);
97
+ if (index >= 0) {
98
+ result[index] = { ...result[index], ...model };
99
+ }
100
+ else {
101
+ result.push({ ...model });
102
+ }
103
+ }
104
+ return result;
105
+ }
106
+ export function mergeProvider(base, override) {
107
+ let models = base.models;
108
+ if (Array.isArray(override.models)) {
109
+ if (override.replaceModels) {
110
+ models = override.models.map((model) => ({ ...model }));
111
+ }
112
+ else {
113
+ models = mergeModels(base.models, override.models);
114
+ }
115
+ }
116
+ return {
117
+ ...base,
118
+ ...override,
119
+ headers: { ...(base.headers || {}), ...(override.headers || {}) },
120
+ models,
121
+ };
122
+ }
123
+ export function mergeProviders(base, override) {
124
+ const result = base.map((provider) => ({ ...provider }));
125
+ for (const provider of override) {
126
+ if (!provider || typeof provider.name !== "string") {
127
+ continue;
128
+ }
129
+ const index = result.findIndex((existing) => existing.name === provider.name);
130
+ if (index >= 0) {
131
+ result[index] = mergeProvider(result[index], provider);
132
+ }
133
+ else {
134
+ result.push({ ...provider, models: provider.models || [] });
135
+ }
136
+ }
137
+ return result;
138
+ }
139
+ function mergeConfig(base, override) {
140
+ const result = { ...base };
141
+ const target = result;
142
+ for (const [key, value] of Object.entries(override)) {
143
+ if (value === undefined || key.startsWith("$")) {
144
+ continue;
145
+ }
146
+ if (key === "providers" && Array.isArray(value)) {
147
+ result.providers = mergeProviders(base.providers, value);
148
+ }
149
+ else if ((key === "instructions" || key === "disabledProviders") && Array.isArray(value)) {
150
+ target[key] = [...new Set([...target[key], ...value])];
151
+ }
152
+ else if (key === "lsp" && value === false) {
153
+ result.lsp = false;
154
+ }
155
+ else if (["permission", "mcp", "lsp", "agents", "commands", "ranking", "skills"].includes(key) && isObject(value)) {
156
+ target[key] = deepMerge(target[key] || {}, value);
157
+ }
158
+ else {
159
+ target[key] = value;
160
+ }
161
+ }
162
+ return result;
163
+ }
164
+ // El config.json que creaba la versión 0.1 traía la lista de proveedores entera (ya anticuada)
165
+ function isOldGeneratedConfig(raw) {
166
+ const oldKeys = ["providers", "maxSteps", "autoCompactTokens", "bashTimeoutMs", "maxWaitMs"];
167
+ if (Object.keys(raw).some((key) => !oldKeys.includes(key))) {
168
+ return false;
169
+ }
170
+ const providers = raw.providers;
171
+ if (!Array.isArray(providers)) {
172
+ return false;
173
+ }
174
+ const names = providers.map((p) => p.name).join(",");
175
+ if (names !== "gemini,openrouter,cerebras,groq,mistral,github,ollama") {
176
+ return false;
177
+ }
178
+ return (raw.maxSteps === 40 && raw.autoCompactTokens === 90000 && raw.bashTimeoutMs === 120000 && raw.maxWaitMs === 90000);
179
+ }
180
+ function readJson(file) {
181
+ try {
182
+ const data = JSON.parse(fs.readFileSync(file, "utf8"));
183
+ if (!isObject(data)) {
184
+ throw new Error("no es un objeto JSON");
185
+ }
186
+ return data;
187
+ }
188
+ catch (err) {
189
+ throw new Error(`No se pudo leer ${file}: ${err.message}`);
190
+ }
191
+ }
192
+ // Config del proyecto: vipcode.json o .vipcode/config.json en la carpeta donde ejecutas vipcode
193
+ export function projectConfigFiles(root) {
194
+ const candidates = [path.join(root, "vipcode.json"), path.join(root, ".vipcode", "config.json")];
195
+ return candidates.filter((file) => path.resolve(file) !== path.resolve(CONFIG_PATH) && fs.existsSync(file));
196
+ }
197
+ // Cambia un valor de tu config global (~/.vipcode/config.json) sin tocar el resto
198
+ export function setGlobalConfigValue(key, value) {
199
+ loadConfig();
200
+ const raw = readJson(CONFIG_PATH);
201
+ raw[key] = value;
202
+ fs.writeFileSync(CONFIG_PATH, JSON.stringify(raw, null, 2));
203
+ }
204
+ export function loadConfig(root = process.cwd()) {
205
+ fs.mkdirSync(HOME_DIR, { recursive: true });
206
+ // La primera vez creamos el config con los valores por defecto para que puedas editarlo
207
+ if (!fs.existsSync(CONFIG_PATH)) {
208
+ fs.writeFileSync(CONFIG_PATH, JSON.stringify(TEMPLATE, null, 2));
209
+ }
210
+ let raw = readJson(CONFIG_PATH);
211
+ if (isOldGeneratedConfig(raw)) {
212
+ fs.copyFileSync(CONFIG_PATH, `${CONFIG_PATH}.bak`);
213
+ fs.writeFileSync(CONFIG_PATH, JSON.stringify(TEMPLATE, null, 2));
214
+ raw = readJson(CONFIG_PATH);
215
+ configNotices.push(`He actualizado ${CONFIG_PATH} al formato nuevo (copia del antiguo en config.json.bak).`);
216
+ }
217
+ let config = mergeConfig(DEFAULT_CONFIG, raw);
218
+ for (const file of projectConfigFiles(root)) {
219
+ config = mergeConfig(config, readJson(file));
220
+ }
221
+ if (!Array.isArray(config.providers)) {
222
+ config.providers = [];
223
+ }
224
+ return config;
225
+ }
package/dist/debug.js ADDED
@@ -0,0 +1,32 @@
1
+ import fs from "node:fs";
2
+ import { DEBUG_LOG_PATH, HOME_DIR } from "./paths.js";
3
+ // Con VIPCODE_DEBUG=1 se apunta en ~/.vipcode/debug.log lo que pasa por dentro (peticiones, errores…)
4
+ const enabled = process.env.VIPCODE_DEBUG === "1" || process.env.VIPCODE_DEBUG === "true";
5
+ export function debugLog(...parts) {
6
+ if (!enabled) {
7
+ return;
8
+ }
9
+ const text = parts
10
+ .map((part) => {
11
+ if (typeof part === "string") {
12
+ return part;
13
+ }
14
+ if (part instanceof Error) {
15
+ return part.stack || part.message;
16
+ }
17
+ try {
18
+ return JSON.stringify(part);
19
+ }
20
+ catch {
21
+ return String(part);
22
+ }
23
+ })
24
+ .join(" ");
25
+ try {
26
+ fs.mkdirSync(HOME_DIR, { recursive: true });
27
+ fs.appendFileSync(DEBUG_LOG_PATH, `[${new Date().toISOString()}] ${text}\n`);
28
+ }
29
+ catch {
30
+ // no es grave si no se puede escribir el log
31
+ }
32
+ }
@@ -0,0 +1,277 @@
1
+ import fs from "node:fs";
2
+ import { readAuth, resolveCredentials } from "./auth.js";
3
+ import { CATALOG, RETIRED_PROVIDERS } from "./catalog.js";
4
+ import { configNotices, isAnonymous, mergeProvider } from "./config.js";
5
+ import { debugLog } from "./debug.js";
6
+ import { HOME_DIR, MODELS_CACHE_PATH } from "./paths.js";
7
+ // Modelos que no sirven para chatear (embeddings, voz, imágenes, vídeo…)
8
+ const NON_CHAT = /embed|bge-|minilm|rerank|guard|safety|moderation|whisper|tts|transcri|speech|audio-|native-audio|image|imagen|flux|diffusion|sdxl|video|veo|lyria|ocr|retriever|reward|nvclip|clip-|cosmos|bevformer|sparsedrive|streampetr|detector|studiovoice|riva|canary|parakeet|paligemma|grounding|aqa|deplot|kosmos|fuyu|neva|translate|computer-use|robotics|-live|usdcode|usdvalidate|usdsearch/i;
9
+ export function readDiscovery() {
10
+ try {
11
+ const data = JSON.parse(fs.readFileSync(MODELS_CACHE_PATH, "utf8"));
12
+ if (data && typeof data === "object" && data.providers) {
13
+ return data;
14
+ }
15
+ }
16
+ catch {
17
+ // todavía no se ha descubierto nada
18
+ }
19
+ return { updated: 0, providers: {} };
20
+ }
21
+ function writeDiscovery(cache) {
22
+ try {
23
+ fs.mkdirSync(HOME_DIR, { recursive: true });
24
+ fs.writeFileSync(MODELS_CACHE_PATH, JSON.stringify(cache, null, 2));
25
+ }
26
+ catch {
27
+ // no es grave si no se puede guardar
28
+ }
29
+ }
30
+ export function discoveryIsStale(maxAgeMs = 24 * 60 * 60 * 1000) {
31
+ return Date.now() - readDiscovery().updated > maxAgeMs;
32
+ }
33
+ // Junta los modelos del catálogo con los descubiertos, manteniendo el orden del catálogo
34
+ function combine(provider, discovered, handAdded = new Set()) {
35
+ const ids = new Set(discovered.map((model) => model.id));
36
+ let base = provider.models;
37
+ if (provider.pruneMissing) {
38
+ // Los que has puesto tú en la config no se quitan nunca
39
+ base = base.filter((model) => ids.has(model.id) || handAdded.has(model.id));
40
+ }
41
+ const known = new Set(provider.models.map((model) => model.id));
42
+ const extra = discovered.filter((model) => !known.has(model.id));
43
+ return [...base, ...extra];
44
+ }
45
+ function cloneProvider(provider) {
46
+ return { ...provider, models: provider.models.map((model) => ({ ...model })) };
47
+ }
48
+ // Lista final de proveedores: tus proveedores propios primero, luego el catálogo con tus cambios
49
+ export function buildProviders(config, local = {}) {
50
+ const cache = readDiscovery();
51
+ const catalog = CATALOG.map(cloneProvider);
52
+ const custom = [];
53
+ const handAdded = new Map();
54
+ for (const user of config.providers) {
55
+ if (!user || typeof user.name !== "string") {
56
+ continue;
57
+ }
58
+ handAdded.set(user.name, new Set((user.models || []).map((model) => model.id)));
59
+ if (RETIRED_PROVIDERS[user.name] && String(user.baseURL || "").includes("models.github.ai")) {
60
+ const notice = `Ignoro el proveedor "${user.name}" de tu config: ${RETIRED_PROVIDERS[user.name]}.`;
61
+ if (!configNotices.includes(notice)) {
62
+ configNotices.push(notice);
63
+ }
64
+ continue;
65
+ }
66
+ const index = catalog.findIndex((provider) => provider.name === user.name);
67
+ if (index >= 0) {
68
+ catalog[index] = mergeProvider(catalog[index], user);
69
+ }
70
+ else {
71
+ custom.push({ ...user, custom: true, models: (user.models || []).map((model) => ({ ...model })) });
72
+ }
73
+ }
74
+ let list = [...custom, ...catalog];
75
+ list = list.map((provider) => {
76
+ const result = cloneProvider(provider);
77
+ const found = cache.providers[provider.name];
78
+ if (found && found.models.length > 0 && !provider.replaceModels) {
79
+ result.models = combine(result, found.models, handAdded.get(provider.name));
80
+ }
81
+ if (local[provider.name]) {
82
+ result.models = combine(result, local[provider.name]);
83
+ }
84
+ result.models = result.models.filter((model) => !model.disabled);
85
+ // Con "keyless": false, los anónimos solo se usan si les has puesto clave
86
+ if (config.keyless === false && isAnonymous(result)) {
87
+ result.noKey = false;
88
+ }
89
+ return result;
90
+ });
91
+ const disabled = new Set(config.disabledProviders || []);
92
+ const enabledOnly = new Set(config.enabledProviders || []);
93
+ return list.filter((provider) => {
94
+ if (disabled.has(provider.name) || provider.enabled === false) {
95
+ return false;
96
+ }
97
+ if (enabledOnly.size > 0 && !enabledOnly.has(provider.name)) {
98
+ return false;
99
+ }
100
+ return true;
101
+ });
102
+ }
103
+ async function fetchJson(url, credentials, provider, timeoutMs, signal) {
104
+ const headers = { accept: "application/json", ...(provider.headers || {}) };
105
+ if (credentials && credentials.apiKey !== "no-key") {
106
+ headers.authorization = `Bearer ${credentials.apiKey}`;
107
+ }
108
+ const signals = [AbortSignal.timeout(timeoutMs)];
109
+ if (signal) {
110
+ signals.push(signal);
111
+ }
112
+ const response = await fetch(url, { headers, signal: AbortSignal.any(signals) });
113
+ if (!response.ok) {
114
+ const body = await response.text().catch(() => "");
115
+ throw new Error(`HTTP ${response.status} ${body.slice(0, 160)}`);
116
+ }
117
+ return response.json();
118
+ }
119
+ function contextOf(item, provider, known) {
120
+ const candidates = [
121
+ item.context_window,
122
+ item.max_context_length,
123
+ item.context_length,
124
+ item.max_model_len,
125
+ item.max_input_tokens,
126
+ item.limits?.max_input_tokens,
127
+ item.top_provider?.context_length,
128
+ item.context_window?.tokens,
129
+ ];
130
+ let context = known?.context || candidates.find((value) => typeof value === "number" && value > 0) || provider.defaultContext || 32000;
131
+ if (provider.contextCap) {
132
+ context = Math.min(context, provider.contextCap);
133
+ }
134
+ return context;
135
+ }
136
+ function isFreeModel(item, provider) {
137
+ const id = String(item.id || "");
138
+ if (provider.discover === "kilo") {
139
+ return item.isFree === true;
140
+ }
141
+ if (provider.discover === "llm7") {
142
+ // Los de pago van en el nivel "pro" y solo se usan pagando por consumo
143
+ return item.tier !== "pro" && item.usage_based_only !== true;
144
+ }
145
+ if (provider.discover === "openrouter") {
146
+ if (id.endsWith(":free")) {
147
+ return true;
148
+ }
149
+ const prompt = item.pricing?.prompt;
150
+ const completion = item.pricing?.completion;
151
+ return prompt !== undefined && Number(prompt) === 0 && Number(completion) === 0;
152
+ }
153
+ return /(^|[:/-])free$/i.test(id);
154
+ }
155
+ function supportsTools(item, provider) {
156
+ if (provider.discover === "openrouter" || provider.discover === "kilo") {
157
+ return Array.isArray(item.supported_parameters) && item.supported_parameters.includes("tools");
158
+ }
159
+ if (item.capabilities && typeof item.capabilities.function_calling === "boolean") {
160
+ return item.capabilities.function_calling;
161
+ }
162
+ if (item.capabilities && typeof item.capabilities.tools === "boolean") {
163
+ return item.capabilities.tools;
164
+ }
165
+ if (typeof item.tools_calling === "boolean") {
166
+ return item.tools_calling;
167
+ }
168
+ return true;
169
+ }
170
+ function supportsVision(item, known) {
171
+ const modalities = item.architecture?.input_modalities || item.input_modalities || item.modalities?.input;
172
+ if (Array.isArray(modalities)) {
173
+ return modalities.includes("image");
174
+ }
175
+ if (item.capabilities && typeof item.capabilities.vision === "boolean") {
176
+ return item.capabilities.vision;
177
+ }
178
+ return Boolean(known?.vision);
179
+ }
180
+ // Convierte la respuesta de /models en nuestra lista de modelos
181
+ export function parseModels(json, provider) {
182
+ const items = Array.isArray(json?.data) ? json.data : Array.isArray(json) ? json : [];
183
+ const filter = provider.modelFilter ? new RegExp(provider.modelFilter, "i") : null;
184
+ const result = [];
185
+ const seen = new Set();
186
+ for (const item of items) {
187
+ let id = String(item?.id || item?.name || "");
188
+ if (id.startsWith("models/")) {
189
+ id = id.slice("models/".length);
190
+ }
191
+ if (!id || seen.has(id) || NON_CHAT.test(id) || id === "openrouter/auto") {
192
+ continue;
193
+ }
194
+ if (filter && !filter.test(id)) {
195
+ continue;
196
+ }
197
+ if (item.active === false || item.object === "embedding") {
198
+ continue;
199
+ }
200
+ // Modelos que no escriben texto (vectores, voz, imágenes): OVHcloud les pone max_completion_tokens 0 y LLM7 otro model_type
201
+ if (item.max_completion_tokens === 0 || (typeof item.model_type === "string" && item.model_type !== "chat")) {
202
+ continue;
203
+ }
204
+ if (item.deprecation && Date.parse(item.deprecation) < Date.now()) {
205
+ continue;
206
+ }
207
+ if (provider.freeOnly && !isFreeModel(item, provider)) {
208
+ continue;
209
+ }
210
+ if (!supportsTools(item, provider)) {
211
+ continue;
212
+ }
213
+ const inputs = item.architecture?.input_modalities;
214
+ if (Array.isArray(inputs) && !inputs.includes("text")) {
215
+ continue;
216
+ }
217
+ const known = provider.models.find((model) => model.id === id);
218
+ seen.add(id);
219
+ result.push({ id, context: contextOf(item, provider, known), vision: supportsVision(item, known) });
220
+ }
221
+ return result;
222
+ }
223
+ // Pide /models a cada proveedor con clave y guarda los modelos nuevos
224
+ export async function refreshModels(providers, options = {}) {
225
+ const auth = readAuth();
226
+ const cache = readDiscovery();
227
+ const summary = { updated: [], failed: [] };
228
+ const targets = providers.filter((provider) => {
229
+ if (!provider.discover || provider.local) {
230
+ return false;
231
+ }
232
+ if (options.only && options.only.length > 0 && !options.only.includes(provider.name)) {
233
+ return false;
234
+ }
235
+ return resolveCredentials(provider, auth) !== null;
236
+ });
237
+ await Promise.allSettled(targets.map(async (provider) => {
238
+ const credentials = resolveCredentials(provider, auth);
239
+ const base = (credentials?.baseURL || provider.baseURL).replace(/\/+$/, "");
240
+ try {
241
+ const json = await fetchJson(`${base}/models`, credentials, provider, 20000, options.signal);
242
+ const models = parseModels(json, provider);
243
+ if (models.length === 0) {
244
+ throw new Error("no devolvió ningún modelo útil");
245
+ }
246
+ const before = new Set((cache.providers[provider.name]?.models || provider.models).map((model) => model.id));
247
+ cache.providers[provider.name] = { updated: Date.now(), models };
248
+ summary.updated.push({ name: provider.name, count: models.length, added: models.map((model) => model.id).filter((id) => !before.has(id)) });
249
+ }
250
+ catch (err) {
251
+ debugLog("descubrimiento", provider.name, err);
252
+ summary.failed.push({ name: provider.name, error: err.message });
253
+ }
254
+ }));
255
+ cache.updated = Date.now();
256
+ writeDiscovery(cache);
257
+ return summary;
258
+ }
259
+ // Comprueba deprisa qué servidores locales (Ollama, LM Studio…) están encendidos y qué modelos tienen
260
+ export async function detectLocal(providers, timeoutMs = 500) {
261
+ const found = {};
262
+ await Promise.allSettled(providers
263
+ .filter((provider) => provider.local && provider.enabled !== false)
264
+ .map(async (provider) => {
265
+ try {
266
+ const json = await fetchJson(`${provider.baseURL.replace(/\/+$/, "")}/models`, null, provider, timeoutMs);
267
+ const models = parseModels(json, provider);
268
+ if (models.length > 0) {
269
+ found[provider.name] = models;
270
+ }
271
+ }
272
+ catch {
273
+ // no está encendido
274
+ }
275
+ }));
276
+ return found;
277
+ }