gongxiangp2/lib/ai-model-http.ts

114 lines
9.8 KiB
TypeScript

import { lookup } from 'node:dns/promises'
import { request as httpsRequest } from 'node:https'
import { isIP } from 'node:net'
import { Readable } from 'node:stream'
import { isPublicRoutableIp } from './proxy-media-security'
import { AiModelError } from './ai-model-access'
export function normalizeAiModelBaseUrl(value: string): string {
let url: URL
try { url = new URL(value.trim()) } catch { throw new AiModelError('请输入有效的模型服务地址') }
if (url.protocol !== 'https:' || (url.port && url.port !== '443') || url.username || url.password || url.search || url.hash || url.hostname.endsWith('.') || /%|\\/.test(value)) throw new AiModelError('模型服务须使用无账号、查询参数的 HTTPS 标准端口地址')
const hostname = url.hostname.replace(/^\[|\]$/g, '').toLowerCase()
if (hostname === 'localhost' || hostname.endsWith('.localhost') || hostname.endsWith('.local') || hostname.endsWith('.internal') || !hostname.includes('.') && !isIP(hostname) || isIP(hostname) && !isPublicRoutableIp(hostname)) throw new AiModelError('模型服务不能使用本机或内网地址', 400, 'unsafe_provider_url')
if (!/^\/(?:[a-zA-Z0-9._~-]+\/?)*$/.test(url.pathname) || url.pathname.split('/').some(part => part === '.' || part === '..')) throw new AiModelError('模型服务路径无效')
return url.href.replace(/\/+$/, '').replace(/\/v1$/, '') + '/v1'
}
export type AiModelResolver = (hostname: string) => Promise<Array<{ address: string; family: number }>>
const resolveAddresses: AiModelResolver = async hostname => isIP(hostname) ? [{ address: hostname, family: isIP(hostname) }] : lookup(hostname, { all: true, verbatim: true })
export async function resolveAiModelPublicAddresses(baseUrl: string, resolver: AiModelResolver = resolveAddresses): Promise<Array<{ address: string; family: number }>> {
const host = new URL(normalizeAiModelBaseUrl(baseUrl)).hostname.replace(/^\[|\]$/g, '')
let addresses: Array<{ address: string; family: number }>
let timeout: ReturnType<typeof setTimeout> | undefined
try {
addresses = await Promise.race([resolver(host), new Promise<never>((_resolve, reject) => { timeout = setTimeout(() => reject(new Error('DNS timeout')), 10000) })])
} catch { throw new AiModelError('模型服务域名无法解析', 502, 'provider_dns_failed') } finally { if (timeout) clearTimeout(timeout) }
if (!addresses.length || addresses.some(value => !isPublicRoutableIp(value.address) || isIP(value.address) !== value.family)) throw new AiModelError('模型服务解析到了受保护地址', 400, 'unsafe_provider_url')
return addresses
}
/** Fixed DNS lookup, original hostname TLS verification, no redirects and bounded streamed response. */
export async function requestAiModel(baseUrl: string, apiKey: string, endpoint: '/models' | '/chat/completions' | '/messages' | '/images/edits' | '/images/generations', body?: string | FormData, idempotencyKey?: string, beforeRequest?: () => Promise<void>): Promise<Response> {
const normalized = normalizeAiModelBaseUrl(baseUrl)
return requestAiModelUrl(normalized, new URL(normalized + endpoint), { Authorization: `Bearer ${apiKey}`, ...(idempotencyKey ? { 'Idempotency-Key': idempotencyKey } : {}) }, endpoint === '/models', body, beforeRequest)
}
/** Only fixed, implemented operations are executable; directory URLs never become arbitrary request targets. */
export async function requestAiModelOperation(baseUrl: string, apiKey: string, path: string, body: string | FormData | undefined, idempotencyKey: string | undefined, protocolHeaders: Record<string, string>, beforeRequest?: () => Promise<void>): Promise<Response> {
const normalized = normalizeAiModelBaseUrl(baseUrl)
const retainedResult = /^\/api\/external\/requests\/[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}\/result$/i.test(path)
const taskResult = /^\/extensions\/v1\/tasks\/[A-Za-z0-9_-]{1,160}\/result$/.test(path)
if ((retainedResult || taskResult) && (body !== undefined || idempotencyKey !== undefined || Object.keys(protocolHeaders).length)) throw new AiModelError('原请求结果仅允许只读查询', 400, 'protocol_invalid')
if (!retainedResult && !['/v1/chat/completions', '/v1/messages', '/v1/images/edits', '/v1/images/generations', '/extensions/v1/tasks'].includes(path) && !/^\/extensions\/v1\/tasks\/[A-Za-z0-9_-]{1,160}\/result$/.test(path)) throw new AiModelError('模型接口尚未适配', 400, 'protocol_unsupported')
const headers: Record<string, string> = { Authorization: `Bearer ${apiKey}` }
if (idempotencyKey) {
if (!/^[\x21-\x7e]{1,128}$/.test(idempotencyKey)) throw new AiModelError('模型请求编号无效')
headers['Idempotency-Key'] = idempotencyKey
}
for (const [key, value] of Object.entries(protocolHeaders)) {
if (!['X-Relay-Route-Option', 'Anthropic-Version'].includes(key) || !/^[A-Za-z0-9._-]{1,160}$/.test(value)) throw new AiModelError('模型协议请求头无效', 400, 'protocol_invalid')
headers[key] = value
}
const target = path.startsWith('/v1/') ? new URL(normalized + path.slice(3)) : new URL(path, normalized)
return requestAiModelUrl(normalized, target, headers, false, body, beforeRequest, body === undefined && path.endsWith('/result') ? 10_000 : undefined)
}
/** Generated private assets stay on the configured provider. No credential-bearing request follows a redirect. */
export async function requestAiModelOutput(baseUrl: string, apiKey: string, outputUrl: string, beforeRequest?: () => Promise<void>): Promise<Response> {
const normalized = normalizeAiModelBaseUrl(baseUrl)
let target: URL
try { target = new URL(outputUrl) } catch { throw new AiModelError('模型产物地址无效', 502, 'output_invalid') }
// Relay assets use an encrypted Base64url reference, not a short database ID.
if (target.origin !== new URL(normalized).origin || target.username || target.password || target.hash || !/^\/api\/external\/assets\/[A-Za-z0-9_-]{32,8192}$/.test(target.pathname)) throw new AiModelError('模型产物不在已适配的私有文件入口', 502, 'output_invalid')
return requestAiModelUrl(normalized, target, { Authorization: `Bearer ${apiKey}` }, false, undefined, beforeRequest)
}
/** This provider publishes a separate typed directory. Its public request never carries the private API key. */
export async function requestAiModelCapabilityCatalog(baseUrl: string, beforeRequest?: () => Promise<void>): Promise<Response> {
const normalized = normalizeAiModelBaseUrl(baseUrl)
if (normalized !== 'https://api.vip98k.com/v1') throw new AiModelError('该上游没有已适配的独立能力目录', 400, 'capability_catalog_unavailable')
return requestAiModelUrl(normalized, new URL('https://api.vip98k.com/api/v2/catalog/models'), {}, true, undefined, beforeRequest)
}
async function requestAiModelUrl(normalized: string, target: URL, headers: Record<string, string>, catalog: boolean, body?: string | FormData, beforeRequest?: () => Promise<void>, timeoutMs?: number): Promise<Response> {
const addresses = await resolveAiModelPublicAddresses(normalized)
let requestBody: Readable | null = null
if (typeof body === 'string') { headers['Content-Type'] = 'application/json'; headers['Content-Length'] = String(Buffer.byteLength(body)); requestBody = Readable.from([body]) }
else if (body) {
const formRequest = new Request(target, { method: 'POST', body })
headers['Content-Type'] = formRequest.headers.get('content-type')!
requestBody = Readable.fromWeb(formRequest.body as any)
}
if (beforeRequest) await beforeRequest()
return new Promise<Response>((resolve, reject) => {
let complete = false
const finish = (error: unknown, response?: Response) => { if (complete) return; complete = true; clearTimeout(timer); error ? reject(error) : resolve(response!) }
const req = httpsRequest(target, {
method: body ? 'POST' : 'GET', headers, agent: false,
lookup: ((_hostname: string, options: any, callback: any) => {
const selected = addresses.filter(address => !options?.family || address.family === options.family)
if (!selected.length) return callback(new Error('Validated address unavailable'))
if (options?.all) callback(null, selected)
else callback(null, selected[0].address, selected[0].family)
}) as any,
}, res => {
if ((res.statusCode || 0) >= 300 && (res.statusCode || 0) < 400) { res.destroy(); finish(new AiModelError('模型服务重定向已拒绝,请配置最终服务地址', 502, 'provider_redirect_rejected')); return }
const limit = catalog ? (target.pathname === '/api/v2/catalog/models' ? 8 : 2) * 1024 * 1024 : 32 * 1024 * 1024
const chunks: Buffer[] = []; let size = 0
if (Number(res.headers['content-length'] || 0) > limit) { res.destroy(); finish(new Error('Provider response exceeds limit')); return }
res.on('data', (chunk: Buffer) => { size += chunk.length; if (size > limit) { res.destroy(); finish(new Error('Provider response exceeds limit')) } else chunks.push(chunk) })
res.on('error', () => finish(new Error('Provider response interrupted')))
res.on('end', () => {
const responseHeaders = new Headers()
for (const name of ['content-type', 'x-request-id']) { const value = res.headers[name]; if (typeof value === 'string') responseHeaders.set(name, value) }
try { finish(null, new Response([204, 205, 304].includes(res.statusCode || 0) ? null : new Uint8Array(Buffer.concat(chunks)), { status: res.statusCode || 502, headers: responseHeaders })) } catch { finish(new Error('Provider response invalid')) }
})
})
const timer = setTimeout(() => { req.destroy(); finish(new Error('Provider request timed out')) }, timeoutMs ?? (catalog ? 20_000 : 180_000))
req.on('error', () => finish(new Error('Provider request failed')))
if (requestBody) { requestBody.on('error', () => { req.destroy(); finish(new Error('Provider request body failed')) }); requestBody.pipe(req) } else req.end()
})
}