Stand-in import of elffuss/code
One parentless commit with only the files that the open seed tasks touch and the modules they import. The full squashed import (SPEC 8.6) is a later step: this tree is not the cutoff tree. T2T-StandIn-Source-Commit: 43f95b7333efe73aeaebf806023f17bffea27aba
This commit is contained in:
122
web/js/providers/api.js
Normal file
122
web/js/providers/api.js
Normal file
@@ -0,0 +1,122 @@
|
||||
// Proveedor genérico para APIs externas (configuración avanzada):
|
||||
// - kind 'openai' → /chat/completions (OpenAI, Ollama, llama-server…)
|
||||
// - kind 'anthropic' → /v1/messages (Claude)
|
||||
// Las llamadas salen DIRECTAS del navegador del usuario al proveedor; la clave
|
||||
// no pasa por ningún servidor nuestro. Streaming SSE en ambos dialectos.
|
||||
import { packHistoryAsync } from '../context.js';
|
||||
|
||||
let cfg = null;
|
||||
export let name = 'API';
|
||||
|
||||
export function configure(c) { cfg = c; name = c.label; }
|
||||
|
||||
export async function load() {
|
||||
if (!cfg) throw new Error('proveedor sin configurar');
|
||||
if (cfg.kind !== 'anthropic' && !cfg.apiKey && !cfg.baseURL.includes('localhost') && cfg.baseURL !== '/v1')
|
||||
throw new Error('falta la clave de API (config avanzada)');
|
||||
}
|
||||
|
||||
export async function chat(history, system, onToken = () => {}, signal = null) {
|
||||
return cfg.kind === 'anthropic'
|
||||
? anthropicChat(history, system, onToken)
|
||||
: openaiChat(history, system, onToken);
|
||||
}
|
||||
|
||||
// ---- OpenAI-compatible ----
|
||||
async function openaiChat(history, system, onToken) {
|
||||
const headers = { 'Content-Type': 'application/json' };
|
||||
if (cfg.apiKey) headers.Authorization = 'Bearer ' + cfg.apiKey;
|
||||
// packHistoryAsync: el empaquetado puede tener que codificar embeddings, así
|
||||
// que se resuelve ANTES de armar el cuerpo. Con el lado semántico apagado
|
||||
// devuelve exactamente lo mismo que la versión síncrona de siempre.
|
||||
const packed = await packHistoryAsync(history, 3000);
|
||||
const body = {
|
||||
model: cfg.model,
|
||||
messages: [{ role: 'system', content: system }, ...packed],
|
||||
stream: true,
|
||||
max_tokens: cfg.maxTokens || 1024,
|
||||
};
|
||||
if (cfg.temperature != null) body.temperature = cfg.temperature;
|
||||
if (cfg.top_p != null) body.top_p = cfg.top_p;
|
||||
if (cfg.thinking != null) body.chat_template_kwargs = { enable_thinking: cfg.thinking };
|
||||
|
||||
const res = await fetch(cfg.baseURL.replace(/\/$/, '') + '/chat/completions', {
|
||||
signal,
|
||||
method: 'POST', headers, body: JSON.stringify(body),
|
||||
});
|
||||
if (!res.ok || !res.body) throw new Error('HTTP ' + res.status + ' ' + (await res.text().catch(() => '')).slice(0, 120));
|
||||
|
||||
let out = '';
|
||||
await readSSE(res.body, payload => {
|
||||
if (payload === '[DONE]') return;
|
||||
const d = JSON.parse(payload).choices?.[0]?.delta || {};
|
||||
if (d.reasoning_content) onToken(d.reasoning_content);
|
||||
if (d.content) { out += d.content; onToken(d.content); }
|
||||
});
|
||||
return out.trim();
|
||||
}
|
||||
|
||||
// ---- Anthropic Messages ----
|
||||
async function anthropicChat(history, system, onToken) {
|
||||
const packed = await packHistoryAsync(history, 3000);
|
||||
const res = await fetch(cfg.baseURL.replace(/\/$/, '') + '/v1/messages', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-api-key': cfg.apiKey,
|
||||
'anthropic-version': '2023-06-01',
|
||||
'anthropic-dangerous-direct-browser-access': 'true',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: cfg.model,
|
||||
max_tokens: cfg.maxTokens || 1024,
|
||||
system,
|
||||
stream: true,
|
||||
messages: forAnthropic(packed),
|
||||
}),
|
||||
});
|
||||
if (!res.ok || !res.body) throw new Error('HTTP ' + res.status + ' ' + (await res.text().catch(() => '')).slice(0, 120));
|
||||
|
||||
let out = '';
|
||||
await readSSE(res.body, payload => {
|
||||
const evt = JSON.parse(payload);
|
||||
if (evt.type === 'content_block_delta' && evt.delta?.text) {
|
||||
out += evt.delta.text; onToken(evt.delta.text);
|
||||
}
|
||||
});
|
||||
return out.trim();
|
||||
}
|
||||
|
||||
// Anthropic exige roles alternos empezando por user; fusiona consecutivos.
|
||||
function forAnthropic(msgs) {
|
||||
const merged = [];
|
||||
for (const m of msgs) {
|
||||
const role = m.role === 'assistant' ? 'assistant' : 'user';
|
||||
const last = merged[merged.length - 1];
|
||||
if (last && last.role === role) last.content += '\n' + m.content;
|
||||
else merged.push({ role, content: m.content });
|
||||
}
|
||||
if (merged[0]?.role === 'assistant') merged.unshift({ role: 'user', content: '(continúa)' });
|
||||
return merged;
|
||||
}
|
||||
|
||||
// Lector SSE común: invoca fn con el texto tras cada 'data:'.
|
||||
async function readSSE(stream, fn) {
|
||||
const reader = stream.getReader();
|
||||
const dec = new TextDecoder();
|
||||
let buf = '';
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buf += dec.decode(value, { stream: true });
|
||||
let nl;
|
||||
while ((nl = buf.indexOf('\n')) >= 0) {
|
||||
const line = buf.slice(0, nl).trim();
|
||||
buf = buf.slice(nl + 1);
|
||||
if (!line.startsWith('data:')) continue;
|
||||
const payload = line.slice(5).trim();
|
||||
if (!payload) continue;
|
||||
try { fn(payload); } catch { /* chunk parcial */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user