mirror of
https://github.com/sune-org/us.proxy.sune.chat.git
synced 2026-04-07 02:02:13 +00:00
347 lines
12 KiB
JavaScript
347 lines
12 KiB
JavaScript
import OpenAI from 'openai'
|
|
import Anthropic from '@anthropic-ai/sdk'
|
|
|
|
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'].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 && p.type !== 'text' && p.type !== 'input_text')
|
|
}
|
|
|
|
function mapPartToResponses(part) {
|
|
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
|
|
}
|
|
if (['text', 'input_text'].includes(type)) return { type: 'input_text', text: String(part.text ?? part.content ?? '') }
|
|
return { type: 'input_text', 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(mapPartToResponses).filter(Boolean)
|
|
: [{ type: 'input_text', text: String(m.content || '') }],
|
|
}))
|
|
}
|
|
|
|
function mapToGoogleContents(messages) {
|
|
const contents = messages.reduce((acc, m) => {
|
|
const role = m.role === 'assistant' ? 'model' : 'user'
|
|
const msgContent = Array.isArray(m.content) ? m.content : [{ type: 'text', text: String(m.content ?? '') }]
|
|
const parts = msgContent.map(p => {
|
|
if (p.type === 'text') return { text: p.text || '' }
|
|
if (p.type === 'image_url' && p.image_url?.url) {
|
|
const match = p.image_url.url.match(/^data:(image\/\w+);base64,(.*)$/)
|
|
if (match) return { inline_data: { mime_type: match[1], data: match[2] } }
|
|
}
|
|
return null
|
|
}).filter(Boolean)
|
|
if (!parts.length) return acc
|
|
if (acc.length > 0 && acc.at(-1).role === role) acc.at(-1).parts.push(...parts)
|
|
else acc.push({ role, parts })
|
|
return acc
|
|
}, [])
|
|
if (contents.at(-1)?.role !== 'user') contents.pop()
|
|
return contents
|
|
}
|
|
|
|
function isOnlineModel(model) {
|
|
return String(model ?? '').endsWith(':online')
|
|
}
|
|
|
|
function stripOnlineSuffix(model) {
|
|
return String(model ?? '').replace(/:online$/, '')
|
|
}
|
|
|
|
function buildOpenAIWebSearchTool(body) {
|
|
if (!isOnlineModel(body?.model)) return null
|
|
|
|
const cfg = body?.web_search || {}
|
|
const tool = {
|
|
type: 'web_search',
|
|
external_web_access: true,
|
|
}
|
|
|
|
const allowedDomains = Array.isArray(cfg.allowed_domains)
|
|
? cfg.allowed_domains
|
|
.map(d => String(d || '').trim().replace(/^https?:\/\//, '').replace(/\/+$/, ''))
|
|
.filter(Boolean)
|
|
.slice(0, 100)
|
|
: []
|
|
if (allowedDomains.length) tool.filters = { allowed_domains: allowedDomains }
|
|
|
|
if (cfg.user_location && typeof cfg.user_location === 'object') {
|
|
const u = cfg.user_location
|
|
const loc = { type: 'approximate' }
|
|
if (u.country) loc.country = String(u.country).slice(0, 2).toUpperCase()
|
|
if (u.city) loc.city = String(u.city)
|
|
if (u.region) loc.region = String(u.region)
|
|
if (u.timezone) loc.timezone = String(u.timezone)
|
|
if (loc.country || loc.city || loc.region || loc.timezone) tool.user_location = loc
|
|
} else if (cfg.use_default_location !== false) {
|
|
tool.user_location = {
|
|
type: 'approximate',
|
|
country: 'US',
|
|
timezone: 'America/Los_Angeles',
|
|
}
|
|
}
|
|
|
|
return tool
|
|
}
|
|
|
|
function collectOpenAISources(finalResponse) {
|
|
const out = []
|
|
const seen = new Set()
|
|
const add = (url, title) => {
|
|
const u = String(url || '').trim()
|
|
if (!u || seen.has(u)) return
|
|
seen.add(u)
|
|
out.push({ url: u, title: String(title || '') })
|
|
}
|
|
|
|
for (const item of finalResponse?.output || []) {
|
|
if (item?.type === 'message') {
|
|
for (const c of item.content || []) {
|
|
for (const a of c?.annotations || []) {
|
|
if (a?.type === 'url_citation' && a?.url) add(a.url, a.title)
|
|
}
|
|
}
|
|
}
|
|
const sources = item?.action?.sources
|
|
if (Array.isArray(sources)) {
|
|
for (const s of sources) add(s?.url || s?.link, s?.title || s?.name)
|
|
}
|
|
}
|
|
|
|
return out
|
|
}
|
|
|
|
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 = isOnlineModel(body.model)
|
|
const model = stripOnlineSuffix(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) {
|
|
const webSearchTool = buildOpenAIWebSearchTool(body)
|
|
if (webSearchTool) {
|
|
params.tools = [...(Array.isArray(body.tools) ? body.tools : []), webSearchTool]
|
|
params.tool_choice = body.tool_choice || 'auto'
|
|
params.include = [...new Set([...(Array.isArray(body.include) ? body.include : []), 'web_search_call.action.sources'])]
|
|
|
|
if (!params.reasoning && /^(gpt-5|o3|o4)/i.test(model)) {
|
|
// Keeps agentic search enabled on reasoning-capable models.
|
|
params.reasoning = { effort: 'medium' }
|
|
}
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
if (online && isRunning()) {
|
|
let finalResponse = null
|
|
try { finalResponse = await stream.finalResponse() } catch {}
|
|
const sources = collectOpenAISources(finalResponse).slice(0, 12)
|
|
if (sources.length) {
|
|
const lines = sources.map((s, i) => `- [${s.title || `Source ${i + 1}`}](${s.url})`)
|
|
onDelta(`\n\nSources:\n${lines.join('\n')}`)
|
|
}
|
|
}
|
|
} finally {
|
|
try { stream.controller?.abort() } catch {}
|
|
try { signal?.aborted || (signal && signal.addEventListener && signal.addEventListener('abort', () => stream.controller?.abort(), { once: true })) } 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 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] } }
|
|
}
|
|
return null
|
|
}).filter(Boolean),
|
|
})).filter(m => m.content.length),
|
|
max_tokens: body.max_tokens || 64000,
|
|
}
|
|
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
|
|
if (body.reasoning?.enabled) {
|
|
payload.extended_thinking = {
|
|
enabled: true,
|
|
...(body.reasoning.budget && { max_thinking_tokens: body.reasoning.budget }),
|
|
}
|
|
}
|
|
if (online) {
|
|
payload.tools = [
|
|
...(payload.tools || []),
|
|
{ type: 'web_search_20250305', name: 'web_search' },
|
|
]
|
|
}
|
|
|
|
const stream = client.messages.stream(payload)
|
|
stream.on('text', text => { if (isRunning()) onDelta(text) })
|
|
await stream.finalMessage()
|
|
}
|
|
|
|
export async function streamGoogle({ apiKey, body, signal, onDelta, isRunning }) {
|
|
const generationConfig = Object.entries({
|
|
temperature: body.temperature,
|
|
topP: body.top_p,
|
|
maxOutputTokens: body.max_tokens,
|
|
}).reduce((acc, [k, v]) => (Number.isFinite(+v) && +v >= 0 ? { ...acc, [k]: +v } : acc), {})
|
|
|
|
if (body.reasoning) {
|
|
generationConfig.thinkingConfig = {
|
|
includeThoughts: body.reasoning.exclude !== true,
|
|
...(body.reasoning.effort && body.reasoning.effort !== 'default' && { thinkingLevel: body.reasoning.effort }),
|
|
}
|
|
}
|
|
if (body.response_format?.type?.startsWith('json')) {
|
|
generationConfig.responseMimeType = 'application/json'
|
|
if (body.response_format.json_schema) {
|
|
const translate = 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() : translate(s[k])
|
|
return n
|
|
}
|
|
generationConfig.responseSchema = translate(body.response_format.json_schema.schema || body.response_format.json_schema)
|
|
}
|
|
}
|
|
|
|
const model = (body.model ?? '').replace(/:online$/, '')
|
|
const payload = {
|
|
contents: mapToGoogleContents(body.messages),
|
|
...(Object.keys(generationConfig).length && { generationConfig }),
|
|
...((body.model ?? '').endsWith(':online') && { tools: [{ google_search: {} }] }),
|
|
}
|
|
|
|
const resp = await fetch(
|
|
`https://generativelanguage.googleapis.com/v1beta/models/${model}:streamGenerateContent?alt=sse`,
|
|
{
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json', 'x-goog-api-key': apiKey },
|
|
body: JSON.stringify(payload),
|
|
signal,
|
|
}
|
|
)
|
|
if (!resp.ok) throw new Error(`Google 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 })
|
|
for (const line of buf.split('\n')) {
|
|
if (!line.startsWith('data: ')) continue
|
|
try {
|
|
JSON.parse(line.substring(6))?.candidates?.[0]?.content?.parts?.forEach(p => {
|
|
if (p.thought?.thought) {
|
|
onDelta(p.thought.thought)
|
|
hasReasoning = true
|
|
}
|
|
if (p.text) {
|
|
if (hasReasoning && !hasContent) onDelta('\n')
|
|
onDelta(p.text)
|
|
hasContent = true
|
|
}
|
|
})
|
|
} catch {}
|
|
}
|
|
buf = buf.slice(buf.lastIndexOf('\n') + 1)
|
|
}
|
|
}
|