File size: 4,536 Bytes
b30b7c5
 
 
 
 
51ec7b5
b30b7c5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
51ec7b5
 
 
 
b30b7c5
 
51ec7b5
b30b7c5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
51ec7b5
b30b7c5
 
 
 
 
 
 
 
 
 
 
 
 
51ec7b5
b30b7c5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
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 = () => {}) {
  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', {
    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 */ }
    }
  }
}