File size: 7,933 Bytes
ca51841
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c43b369
 
 
 
 
 
 
 
ca51841
c43b369
 
 
 
 
 
 
 
 
 
 
 
 
ca51841
 
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
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
const DEV_PROXY_BASE = '/lw-proxy';
const NON_ASCII_TOKEN_ESTIMATE = 1;
const ASCII_CHARS_PER_TOKEN = 4;
const IMAGE_MESSAGE_TOKEN_ESTIMATE = 256;

/**
 * Builds the fetch URL and headers for a given API call.
 * In dev mode: routes through the Vite CORS proxy (/lw-proxy) using X-LW-Target.
 * In prod mode: calls the baseUrl directly (server must have CORS configured).
 *
 * Exported for unit testing with an explicit isDev parameter.
 */
export function buildRequestConfig(baseUrl, apiKey, isDev = import.meta.env.DEV, options = {}) {
  const cleanBase = String(baseUrl || '').trim().replace(/\/+$/, '');
  const { contentType = 'application/json' } = options;
  const headers = {};
  if (contentType) headers['Content-Type'] = contentType;
  if (apiKey) headers['Authorization'] = `Bearer ${apiKey}`;

  if (isDev) {
    headers['X-LW-Target'] = cleanBase;
    return { urlBase: DEV_PROXY_BASE, headers };
  }
  return { urlBase: cleanBase, headers };
}

export async function fetchModels(baseUrl, apiKey) {
  const { urlBase, headers } = buildRequestConfig(baseUrl, apiKey);
  const url = `${urlBase}/v1/models`;
  const res = await fetch(url, { headers });
  if (!res.ok) {
    const text = await res.text().catch(() => '');
    throw new Error(`Failed to fetch models (${res.status}): ${text.slice(0, 200)}`);
  }
  const data = await res.json();
  // OpenAI returns { data: [{ id, ... }] }
  const list = data.data || data.models || data;
  return Array.isArray(list) ? list.map(m => (typeof m === 'string' ? m : m.id)).filter(Boolean).sort() : [];
}

function extractAudioText(payload) {
  if (typeof payload === 'string') return payload.trim();
  if (typeof payload?.text === 'string') return payload.text.trim();
  if (typeof payload?.transcript === 'string') return payload.transcript.trim();
  if (typeof payload?.output_text === 'string') return payload.output_text.trim();
  if (Array.isArray(payload?.segments)) {
    return payload.segments
      .map((segment) => String(segment?.text || '').trim())
      .filter(Boolean)
      .join(' ')
      .trim();
  }
  return '';
}

async function submitAudioTextRequest(baseUrl, apiKey, model, file, endpoint, options = {}) {
  const { prompt = '', language = '' } = options;
  const { urlBase, headers } = buildRequestConfig(baseUrl, apiKey, import.meta.env.DEV, { contentType: null });
  const url = `${urlBase}${endpoint}`;
  const body = new FormData();

  body.append('file', file, file?.name || 'audio.wav');
  if (model) body.append('model', model);
  if (prompt) body.append('prompt', prompt);
  if (language) body.append('language', language);
  body.append('response_format', 'json');

  const res = await fetch(url, {
    method: 'POST',
    headers,
    body,
  });

  if (!res.ok) {
    const text = await res.text().catch(() => '');
    let errMsg = `API error (${res.status})`;
    try {
      const json = JSON.parse(text);
      errMsg = json.error?.message || errMsg;
    } catch {
      if (text) errMsg += ': ' + text.slice(0, 200);
    }
    throw new Error(errMsg);
  }

  const payload = await res.json().catch(async () => {
    const text = await res.text().catch(() => '');
    return { text };
  });
  const text = extractAudioText(payload);
  if (!text) throw new Error('Audio API returned an empty text result');
  return text;
}

export async function transcribeAudio(baseUrl, apiKey, model, file, options = {}) {
  return submitAudioTextRequest(baseUrl, apiKey, model, file, '/v1/audio/transcriptions', options);
}

export function estimateTextTokens(text) {
  const value = String(text || '');
  let asciiChars = 0;
  let nonAsciiTokens = 0;

  for (const char of value) {
    if (/\s/.test(char)) continue;
    if (char.charCodeAt(0) <= 0x7f) {
      asciiChars += 1;
    } else {
      nonAsciiTokens += NON_ASCII_TOKEN_ESTIMATE;
    }
  }

  return nonAsciiTokens + Math.ceil(asciiChars / ASCII_CHARS_PER_TOKEN);
}

function estimateContentTokens(content) {
  if (typeof content === 'string') return estimateTextTokens(content);
  if (!Array.isArray(content)) return 0;

  return content.reduce((total, part) => {
    if (part?.type === 'text') return total + estimateTextTokens(part.text);
    if (part?.type === 'image_url') return total + IMAGE_MESSAGE_TOKEN_ESTIMATE;
    if (part?.type === 'video_url') return total + IMAGE_MESSAGE_TOKEN_ESTIMATE * 8; // rough: ~8 keyframes
    return total;
  }, 0);
}

export function estimateMessageTokens(messages) {
  if (!Array.isArray(messages) || messages.length === 0) return 0;

  return messages.reduce((total, message) => {
    return total + 4 + estimateContentTokens(message.content);
  }, 2);
}

export function formatCompactTokenCount(tokens) {
  const safe = Math.max(0, Math.round(Number(tokens) || 0));

  if (safe < 1000) return String(safe);
  if (safe < 100000) return `${(safe / 1000).toFixed(1).replace(/\.0$/, '')}k`;
  return `${Math.round(safe / 1000)}k`;
}

export async function* streamCompletion(baseUrl, apiKey, model, messages, options = {}) {
  const { onUsage } = options;
  const { urlBase, headers } = buildRequestConfig(baseUrl, apiKey);
  const url = `${urlBase}/v1/chat/completions`;
  const res = await fetch(url, {
    method: 'POST',
    headers,
    body: JSON.stringify({ model, messages, stream: true }),
  });

  if (!res.ok) {
    const text = await res.text().catch(() => '');
    let errMsg = `API error (${res.status})`;
    try {
      const json = JSON.parse(text);
      errMsg = json.error?.message || errMsg;
    } catch {
      if (text) errMsg += ': ' + text.slice(0, 200);
    }
    throw new Error(errMsg);
  }

  const reader = res.body.getReader();
  const decoder = new TextDecoder();
  let buffer = '';

  while (true) {
    const { done, value } = await reader.read();
    if (done) break;

    buffer += decoder.decode(value, { stream: true });
    const lines = buffer.split('\n');
    buffer = lines.pop(); // keep incomplete line

    for (const line of lines) {
      const trimmed = line.trim();
      if (!trimmed) continue;
      if (!trimmed.startsWith('data: ')) continue;

      const data = trimmed.slice(6);
      if (data === '[DONE]') return;

      try {
        const json = JSON.parse(data);
        if (json.usage?.total_tokens != null) onUsage?.(json.usage);
        const delta = json.choices?.[0]?.delta;
        if (delta?.content) yield delta.content;
      } catch {
        // ignore malformed SSE lines
      }
    }
  }

  // flush any remaining buffer
  if (buffer.trim().startsWith('data: ')) {
    const data = buffer.trim().slice(6);
    if (data && data !== '[DONE]') {
      try {
        const json = JSON.parse(data);
        if (json.usage?.total_tokens != null) onUsage?.(json.usage);
        const delta = json.choices?.[0]?.delta;
        if (delta?.content) yield delta.content;
      } catch { /* ignore */ }
    }
  }
}

export function formatMessagesForApi(messages) {
  // Find the index of the last user message so we can preserve its media dataUrls.
  // Historical video messages are stripped (huge dataUrls, already processed by the model).
  let lastUserIdx = -1;
  for (let i = messages.length - 1; i >= 0; i--) {
    if (messages[i].role === 'user') { lastUserIdx = i; break; }
  }

  return messages.map((msg, i) => {
    if (msg.role === 'assistant') return { role: 'assistant', content: msg.content };

    // For the most recent user message keep content verbatim (includes any media dataUrl).
    if (i === lastUserIdx || !Array.isArray(msg.content)) {
      return { role: 'user', content: msg.content };
    }

    // For historical user messages: replace video_url parts with a text note so the
    // API payload stays small and uses only standard content types.
    const content = msg.content.map(part => {
      if (part?.type === 'video_url') return { type: 'text', text: '[Video attachment]' };
      return part;
    });
    return { role: 'user', content };
  });
}