mirror of
https://github.com/sune-org/us.proxy.sune.chat.git
synced 2026-08-28 09:35:42 +00:00
367 lines
13 KiB
JavaScript
367 lines
13 KiB
JavaScript
import OpenAI from 'openai'
|
|
import Anthropic from '@anthropic-ai/sdk'
|
|
import { GoogleGenAI } from '@google/genai'
|
|
|
|
function extractText(m) {
|
|
if (!m) return ''
|
|
if (typeof m.content === 'string') return m.content
|
|
if (!Array.isArray(m.content)) return ''
|
|
return m.content.filter(p => p && ['text', 'input_text', 'output_text'].includes(p.type)).map(p => p.text ?? p.content ?? '').join('')
|
|
}
|
|
|
|
function isMultimodal(m) {
|
|
return m && Array.isArray(m.content) && m.content.some(p => p?.type && !['text', 'input_text', 'output_text'].includes(p.type))
|
|
}
|
|
|
|
function mapPartToResponses(part, role) {
|
|
const type = part?.type || 'text'
|
|
if (['image_url', 'input_image'].includes(type)) {
|
|
const url = part?.image_url?.url || part?.image_url
|
|
return url ? { type: 'input_image', image_url: String(url) } : null
|
|
}
|
|
const textType = role === 'assistant' ? 'output_text' : 'input_text'
|
|
if (['text', 'input_text', 'output_text'].includes(type)) return { type: textType, text: String(part.text ?? part.content ?? '') }
|
|
return { type: textType, text: `[${type}:${part?.file?.filename || 'file'}]` }
|
|
}
|
|
|
|
function buildInputForResponses(messages) {
|
|
if (!Array.isArray(messages) || !messages.length) return ''
|
|
if (!messages.some(isMultimodal)) {
|
|
if (messages.length === 1) return extractText(messages[0])
|
|
return messages.map(m => ({ role: m.role, content: extractText(m) }))
|
|
}
|
|
return messages.map(m => ({
|
|
role: m.role,
|
|
content: Array.isArray(m.content)
|
|
? m.content.map(p => mapPartToResponses(p, m.role)).filter(Boolean)
|
|
: [{ type: m.role === 'assistant' ? 'output_text' : 'input_text', text: String(m.content || '') }],
|
|
}))
|
|
}
|
|
|
|
/* ---------- Google helpers ---------- */
|
|
|
|
const THINKING_LEVELS = { none: 'minimal', minimal: 'minimal', low: 'low', medium: 'medium', high: 'high' }
|
|
|
|
const EXT_MIME = {
|
|
pdf: 'application/pdf', png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', webp: 'image/webp',
|
|
gif: 'image/gif', heic: 'image/heic', heif: 'image/heif', bmp: 'image/bmp',
|
|
mp3: 'audio/mp3', wav: 'audio/wav', ogg: 'audio/ogg', flac: 'audio/flac', aac: 'audio/aac', m4a: 'audio/mp4',
|
|
mp4: 'video/mp4', mov: 'video/quicktime', webm: 'video/webm',
|
|
txt: 'text/plain', md: 'text/md', csv: 'text/csv', xml: 'text/xml', rtf: 'text/rtf',
|
|
}
|
|
|
|
const mimeFromName = n => EXT_MIME[String(n || '').split('.').pop().toLowerCase()] || 'text/plain'
|
|
|
|
const inlineFromDataUrl = u => {
|
|
const m = String(u || '').match(/^data:([^;,]+);base64,(.*)$/s)
|
|
return m ? { inlineData: { mimeType: m[1], data: m[2] } } : null
|
|
}
|
|
|
|
function mapPartToGoogle(p) {
|
|
if (!p) return null
|
|
if (typeof p === 'string') return p.trim() ? { text: p } : null
|
|
switch (p.type) {
|
|
case 'text':
|
|
return p.text?.trim() ? { text: p.text } : null
|
|
case 'image_url':
|
|
return inlineFromDataUrl(p.image_url?.url || p.image_url)
|
|
case 'input_audio':
|
|
return p.input_audio?.data
|
|
? { inlineData: { mimeType: p.input_audio.format === 'mp3' ? 'audio/mp3' : 'audio/wav', data: p.input_audio.data } }
|
|
: null
|
|
case 'file': {
|
|
const d = p.file?.file_data
|
|
if (!d) return null
|
|
return d.startsWith('data:') ? inlineFromDataUrl(d) : { inlineData: { mimeType: mimeFromName(p.file.filename), data: d } }
|
|
}
|
|
default:
|
|
return null
|
|
}
|
|
}
|
|
|
|
const isBlankTurn = c => c.parts.every(p => 'text' in p) && ['', '.'].includes(c.parts.map(p => p.text).join('').trim())
|
|
|
|
function mapToGoogleContents(messages) {
|
|
const contents = []
|
|
for (const m of messages) {
|
|
if (!m || m.role === 'system') continue
|
|
const role = m.role === 'assistant' ? 'model' : 'user'
|
|
const src = Array.isArray(m.content) ? m.content : [{ type: 'text', text: String(m.content ?? '') }]
|
|
const parts = src.map(mapPartToGoogle).filter(Boolean)
|
|
for (const img of m.images || []) {
|
|
const ip = inlineFromDataUrl(img?.image_url?.url || img?.image_url)
|
|
if (ip) parts.push(ip)
|
|
}
|
|
if (!parts.length) continue
|
|
const last = contents.at(-1)
|
|
if (last?.role === role) last.parts.push(...parts)
|
|
else contents.push({ role, parts })
|
|
}
|
|
while (contents.length && contents.at(-1).role === 'model' && isBlankTurn(contents.at(-1))) contents.pop()
|
|
return contents
|
|
}
|
|
|
|
function toGoogleSchema(s) {
|
|
if (typeof s !== 'object' || s === null) return s
|
|
const n = Array.isArray(s) ? [] : {}
|
|
for (const k in s) if (Object.hasOwn(s, k)) n[k] = (k === 'type' && typeof s[k] === 'string') ? s[k].toUpperCase() : toGoogleSchema(s[k])
|
|
return n
|
|
}
|
|
|
|
function collectSources(candidate, sources) {
|
|
for (const c of candidate?.groundingMetadata?.groundingChunks || []) {
|
|
const uri = c?.web?.uri
|
|
if (uri && !sources.has(uri)) sources.set(uri, c?.web?.title || uri)
|
|
}
|
|
for (const u of candidate?.urlContextMetadata?.urlMetadata || []) {
|
|
const uri = u?.retrievedUrl
|
|
if (uri && (!u.urlRetrievalStatus || u.urlRetrievalStatus === 'URL_RETRIEVAL_STATUS_SUCCESS') && !sources.has(uri)) {
|
|
sources.set(uri, u?.title || uri)
|
|
}
|
|
}
|
|
}
|
|
|
|
/* ---------- Providers ---------- */
|
|
|
|
export async function streamOpenRouter({ apiKey, body, signal, onDelta, isRunning }) {
|
|
const resp = await fetch('https://openrouter.ai/api/v1/chat/completions', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Authorization': `Bearer ${apiKey}`,
|
|
'Content-Type': 'application/json',
|
|
'HTTP-Referer': 'https://sune.chat',
|
|
'X-Title': 'Sune',
|
|
},
|
|
body: JSON.stringify(body),
|
|
signal,
|
|
})
|
|
if (!resp.ok) throw new Error(`OpenRouter API error: ${resp.status} ${await resp.text()}`)
|
|
|
|
const reader = resp.body.getReader()
|
|
const dec = new TextDecoder()
|
|
let buf = '', hasReasoning = false, hasContent = false
|
|
|
|
while (isRunning()) {
|
|
const { done, value } = await reader.read()
|
|
if (done) break
|
|
buf += dec.decode(value, { stream: true })
|
|
const lines = buf.split('\n')
|
|
buf = lines.pop()
|
|
for (const line of lines) {
|
|
if (!line.startsWith('data: ')) continue
|
|
const data = line.substring(6).trim()
|
|
if (data === '[DONE]') return
|
|
try {
|
|
const delta = JSON.parse(data).choices?.[0]?.delta
|
|
if (!delta) continue
|
|
if (delta.reasoning && body.reasoning?.exclude !== true) {
|
|
onDelta(delta.reasoning)
|
|
hasReasoning = true
|
|
}
|
|
if (delta.content) {
|
|
if (hasReasoning && !hasContent) onDelta('\n')
|
|
onDelta(delta.content)
|
|
hasContent = true
|
|
}
|
|
if (delta.images) onDelta('', delta.images)
|
|
} catch {}
|
|
}
|
|
}
|
|
}
|
|
|
|
export async function streamOpenAI({ apiKey, body, signal, onDelta, isRunning }) {
|
|
const client = new OpenAI({ apiKey })
|
|
const online = (body.model ?? '').endsWith(':online')
|
|
const model = online ? body.model.slice(0, -7) : body.model
|
|
|
|
const params = {
|
|
model,
|
|
input: buildInputForResponses(body.messages || []),
|
|
temperature: body.temperature,
|
|
stream: true,
|
|
}
|
|
if (Number.isFinite(+body.max_tokens) && +body.max_tokens > 0) params.max_output_tokens = +body.max_tokens
|
|
if (Number.isFinite(+body.top_p)) params.top_p = +body.top_p
|
|
if (body.reasoning?.effort) params.reasoning = { effort: body.reasoning.effort }
|
|
if (body.verbosity) params.text = { verbosity: body.verbosity }
|
|
|
|
if (online) {
|
|
params.tools = [
|
|
...(params.tools || []),
|
|
{ type: 'web_search', external_web_access: true },
|
|
]
|
|
}
|
|
|
|
const stream = await client.responses.stream(params)
|
|
try {
|
|
for await (const event of stream) {
|
|
if (!isRunning()) break
|
|
if (event.type.endsWith('.delta') && event.delta) onDelta(event.delta)
|
|
}
|
|
} finally {
|
|
try { stream.controller?.abort() } catch {}
|
|
}
|
|
}
|
|
|
|
export async function streamClaude({ apiKey, body, signal, onDelta, isRunning }) {
|
|
const client = new Anthropic({ apiKey })
|
|
const online = (body.model ?? '').endsWith(':online')
|
|
const model = online ? body.model.slice(0, -7) : body.model
|
|
const CLAUDE_MAX_TOKENS = 128000
|
|
|
|
const system = body.messages
|
|
.filter(m => m.role === 'system')
|
|
.map(extractText)
|
|
.join('\n\n') || body.system
|
|
const payload = {
|
|
model,
|
|
messages: body.messages.filter(m => m.role !== 'system').map(m => ({
|
|
role: m.role,
|
|
content: typeof m.content === 'string' ? m.content : (m.content || []).map(p => {
|
|
if (p.type === 'text' && p.text) return { type: 'text', text: p.text }
|
|
if (p.type === 'image_url') {
|
|
const match = String(p.image_url?.url || p.image_url || '').match(/^data:(image\/\w+);base64,(.*)$/)
|
|
if (match) return { type: 'image', source: { type: 'base64', media_type: match[1], data: match[2] } }
|
|
}
|
|
if (p.type === 'document' && p.source) return p
|
|
if (p.type === 'file' && p.file?.file_data) {
|
|
return {
|
|
type: 'document',
|
|
source: {
|
|
type: 'base64',
|
|
media_type: 'application/pdf',
|
|
data: p.file.file_data,
|
|
},
|
|
}
|
|
}
|
|
return null
|
|
}).filter(Boolean),
|
|
})).filter(m => m.content.length),
|
|
max_tokens: CLAUDE_MAX_TOKENS,
|
|
}
|
|
if (system) payload.system = system
|
|
if (Number.isFinite(+body.temperature)) payload.temperature = +body.temperature
|
|
if (Number.isFinite(+body.top_p)) payload.top_p = +body.top_p
|
|
|
|
const effort = body.reasoning?.effort
|
|
if (effort === 'none') {
|
|
payload.thinking = { type: 'disabled' }
|
|
} else if (effort && effort !== 'default') {
|
|
payload.thinking = { type: 'adaptive' }
|
|
payload.output_config = { effort }
|
|
}
|
|
|
|
if (online) {
|
|
payload.tools = [
|
|
...(payload.tools || []),
|
|
{ type: 'web_search_20260318', name: 'web_search', allowed_callers: ['direct'] },
|
|
]
|
|
}
|
|
|
|
const includeThoughts = body.reasoning?.exclude !== true
|
|
let hasThinking = false, hasContent = false
|
|
const sources = new Set()
|
|
|
|
const stream = client.messages.stream(payload)
|
|
try {
|
|
for await (const event of stream) {
|
|
if (!isRunning()) break
|
|
if (event.type !== 'content_block_delta') continue
|
|
const delta = event.delta
|
|
if (delta.type === 'thinking_delta' && includeThoughts) {
|
|
onDelta(delta.thinking)
|
|
hasThinking = true
|
|
} else if (delta.type === 'text_delta') {
|
|
if (hasThinking && !hasContent) onDelta('\n')
|
|
onDelta(delta.text)
|
|
hasContent = true
|
|
} else if (delta.type === 'citations_delta' && delta.citation) {
|
|
const uri = delta.citation.url || delta.citation.source
|
|
if (uri) sources.add(uri)
|
|
}
|
|
}
|
|
} finally {
|
|
try { stream.controller?.abort() } catch {}
|
|
}
|
|
|
|
if (sources.size && isRunning()) {
|
|
const list = [...sources].map((uri, i) => `${i + 1}. [${uri}](${uri})`).join('\n')
|
|
onDelta(`\n\n---\n\n**Sources**\n\n${list}\n`)
|
|
}
|
|
}
|
|
|
|
export async function streamGoogle({ apiKey, body, signal, onDelta, isRunning }) {
|
|
const ai = new GoogleGenAI({ apiKey })
|
|
const raw = body.model ?? ''
|
|
const online = raw.endsWith(':online')
|
|
const model = (online ? raw.slice(0, -7) : raw).replace(/^models\//, '')
|
|
|
|
const config = {
|
|
abortSignal: signal,
|
|
maxOutputTokens: Number.isFinite(+body.max_tokens) && +body.max_tokens > 0 ? +body.max_tokens : 65536,
|
|
}
|
|
if (Number.isFinite(+body.temperature)) config.temperature = +body.temperature
|
|
if (Number.isFinite(+body.top_p)) config.topP = +body.top_p
|
|
|
|
const systemInstruction = body.messages.filter(m => m.role === 'system').map(extractText).filter(Boolean).join('\n\n')
|
|
if (systemInstruction) config.systemInstruction = systemInstruction
|
|
|
|
const includeThoughts = body.reasoning?.exclude !== true
|
|
if (body.reasoning) {
|
|
const level = THINKING_LEVELS[String(body.reasoning.effort || '').toLowerCase()]
|
|
config.thinkingConfig = { includeThoughts, ...(level && { thinkingLevel: level }) }
|
|
}
|
|
|
|
if (online) config.tools = [{ googleSearch: {} }, { urlContext: {} }]
|
|
|
|
if (body.modalities?.includes('image')) {
|
|
config.responseModalities = ['TEXT', 'IMAGE']
|
|
config.imageConfig = {
|
|
aspectRatio: body.image_config?.aspect_ratio || '1:1',
|
|
imageSize: body.image_config?.image_size || '1K',
|
|
}
|
|
}
|
|
|
|
if (body.response_format?.type?.startsWith('json')) {
|
|
config.responseMimeType = 'application/json'
|
|
const schema = body.response_format.json_schema
|
|
if (schema) config.responseSchema = toGoogleSchema(schema.schema || schema)
|
|
}
|
|
|
|
const contents = mapToGoogleContents(body.messages)
|
|
if (!contents.length) throw new Error('Google API error: no usable content')
|
|
|
|
const sources = new Map()
|
|
let hasReasoning = false, hasContent = false
|
|
|
|
const stream = await ai.models.generateContentStream({ model, contents, config })
|
|
|
|
for await (const chunk of stream) {
|
|
if (!isRunning()) return
|
|
const candidate = chunk.candidates?.[0]
|
|
collectSources(candidate, sources)
|
|
for (const part of candidate?.content?.parts || []) {
|
|
const inline = part.inlineData
|
|
if (inline?.data && String(inline.mimeType || '').startsWith('image/')) {
|
|
onDelta('', [{ image_url: { url: `data:${inline.mimeType};base64,${inline.data}` } }])
|
|
continue
|
|
}
|
|
if (!part.text) continue
|
|
if (part.thought) {
|
|
if (!includeThoughts) continue
|
|
onDelta(part.text)
|
|
hasReasoning = true
|
|
} else {
|
|
if (hasReasoning && !hasContent) onDelta('\n')
|
|
onDelta(part.text)
|
|
hasContent = true
|
|
}
|
|
}
|
|
}
|
|
|
|
if (sources.size && isRunning()) {
|
|
const list = [...sources.entries()].map(([uri, title], i) => `${i + 1}. [${title}](${uri})`).join('\n')
|
|
onDelta(`\n\n---\n\n**Sources**\n\n${list}\n`)
|
|
}
|
|
}
|