mirror of
https://github.com/QuantumNous/new-api.git
synced 2026-09-06 17:46:23 +00:00
617 lines
17 KiB
TypeScript
Vendored
617 lines
17 KiB
TypeScript
Vendored
/*
|
|
Copyright (C) 2023-2026 QuantumNous
|
|
|
|
This program is free software: you can redistribute it and/or modify
|
|
it under the terms of the GNU Affero General Public License as
|
|
published by the Free Software Foundation, either version 3 of the
|
|
License, or (at your option) any later version.
|
|
|
|
This program is distributed in the hope that it will be useful,
|
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
GNU Affero General Public License for more details.
|
|
|
|
You should have received a copy of the GNU Affero General Public License
|
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
|
|
For commercial licensing, please contact support@quantumnous.com
|
|
*/
|
|
import type {
|
|
AdvancedCustomAuthType,
|
|
AdvancedCustomConfig,
|
|
AdvancedCustomConverter,
|
|
AdvancedCustomRoute,
|
|
AdvancedCustomRouteAuth,
|
|
} from '../types'
|
|
|
|
export const CHANNEL_TYPE_ADVANCED_CUSTOM = 58
|
|
|
|
export const ADVANCED_CUSTOM_CONVERTER_OPTIONS: Array<{
|
|
value: AdvancedCustomConverter
|
|
label: string
|
|
}> = [
|
|
{ value: 'none', label: 'Native forwarding' },
|
|
{
|
|
value: 'anthropic_messages_to_openai_chat_completions',
|
|
label: 'Anthropic Messages to OpenAI Chat',
|
|
},
|
|
{
|
|
value: 'openai_chat_completions_to_anthropic_messages',
|
|
label: 'OpenAI Chat to Anthropic Messages',
|
|
},
|
|
{
|
|
value: 'openai_chat_completions_to_openai_responses',
|
|
label: 'OpenAI Chat to OpenAI Responses',
|
|
},
|
|
{
|
|
value: 'gemini_generate_content_to_openai_chat_completions',
|
|
label: 'Gemini Generate Content to OpenAI Chat',
|
|
},
|
|
{
|
|
value: 'openai_chat_completions_to_gemini_generate_content',
|
|
label: 'OpenAI Chat to Gemini Generate Content',
|
|
},
|
|
]
|
|
|
|
export type AdvancedCustomAuthMode = 'default' | AdvancedCustomAuthType
|
|
|
|
export const ADVANCED_CUSTOM_AUTH_MODE_OPTIONS: Array<{
|
|
value: AdvancedCustomAuthMode
|
|
label: string
|
|
}> = [
|
|
{ value: 'default', label: 'Default Bearer' },
|
|
{ value: 'none', label: 'No Auth' },
|
|
{ value: 'header', label: 'Header' },
|
|
{ value: 'query', label: 'Query' },
|
|
]
|
|
|
|
export type AdvancedCustomIncomingPathOption = {
|
|
value: string
|
|
label: string
|
|
}
|
|
|
|
export const ADVANCED_CUSTOM_INCOMING_PATH_OPTIONS: AdvancedCustomIncomingPathOption[] =
|
|
[
|
|
{
|
|
value: '/v1/chat/completions',
|
|
label: 'OpenAI Chat Completions',
|
|
},
|
|
{
|
|
value: '/v1/completions',
|
|
label: 'OpenAI Completions',
|
|
},
|
|
{
|
|
value: '/v1/responses',
|
|
label: 'OpenAI Responses',
|
|
},
|
|
{
|
|
value: '/v1/responses/compact',
|
|
label: 'OpenAI Responses Compact',
|
|
},
|
|
{
|
|
value: '/v1/embeddings',
|
|
label: 'OpenAI Embeddings',
|
|
},
|
|
{
|
|
value: '/v1/images/generations',
|
|
label: 'OpenAI Image Generations',
|
|
},
|
|
{
|
|
value: '/v1/images/edits',
|
|
label: 'OpenAI Image Edits',
|
|
},
|
|
{
|
|
value: '/v1/audio/speech',
|
|
label: 'OpenAI Audio Speech',
|
|
},
|
|
{
|
|
value: '/v1/audio/transcriptions',
|
|
label: 'OpenAI Audio Transcriptions',
|
|
},
|
|
{
|
|
value: '/v1/audio/translations',
|
|
label: 'OpenAI Audio Translations',
|
|
},
|
|
{
|
|
value: '/v1/rerank',
|
|
label: 'OpenAI Rerank',
|
|
},
|
|
{
|
|
value: '/v1/realtime',
|
|
label: 'OpenAI Realtime',
|
|
},
|
|
{
|
|
value: '/v1/messages',
|
|
label: 'Claude Messages',
|
|
},
|
|
{
|
|
value: '/v1beta/models/{model}:generateContent',
|
|
label: 'Gemini Generate Content',
|
|
},
|
|
{
|
|
value: '/v1beta/models/{model}:embedContent',
|
|
label: 'Gemini Embed Content',
|
|
},
|
|
{
|
|
value: '/v1beta/models/{model}:batchEmbedContents',
|
|
label: 'Gemini Batch Embed Contents',
|
|
},
|
|
]
|
|
|
|
export type AdvancedCustomValidationError = {
|
|
message: string
|
|
routeIndex?: number
|
|
}
|
|
|
|
export type AdvancedCustomTemplateOption = {
|
|
value: string
|
|
label: string
|
|
config: AdvancedCustomConfig
|
|
}
|
|
|
|
const bearerHeaderAuth = (): AdvancedCustomRouteAuth => ({
|
|
type: 'header',
|
|
name: 'Authorization',
|
|
value: 'Bearer {api_key}',
|
|
})
|
|
|
|
const apiKeyHeaderAuth = (): AdvancedCustomRouteAuth => ({
|
|
type: 'header',
|
|
name: 'x-api-key',
|
|
value: '{api_key}',
|
|
})
|
|
|
|
const geminiQueryAuth = (): AdvancedCustomRouteAuth => ({
|
|
type: 'query',
|
|
name: 'key',
|
|
value: '{api_key}',
|
|
})
|
|
|
|
export const ADVANCED_CUSTOM_TEMPLATE_OPTIONS: AdvancedCustomTemplateOption[] =
|
|
[
|
|
{
|
|
value: 'official_openai_chat',
|
|
label: 'Official OpenAI Chat',
|
|
config: {
|
|
advanced_routes: [
|
|
{
|
|
incoming_path: '/v1/chat/completions',
|
|
upstream_path: 'https://api.openai.com/v1/chat/completions',
|
|
converter: 'none',
|
|
auth: bearerHeaderAuth(),
|
|
},
|
|
],
|
|
advanced_fallback: { enabled: false },
|
|
},
|
|
},
|
|
{
|
|
value: 'official_openai_responses',
|
|
label: 'Official OpenAI Responses',
|
|
config: {
|
|
advanced_routes: [
|
|
{
|
|
incoming_path: '/v1/responses',
|
|
upstream_path: 'https://api.openai.com/v1/responses',
|
|
converter: 'none',
|
|
auth: bearerHeaderAuth(),
|
|
},
|
|
],
|
|
advanced_fallback: { enabled: false },
|
|
},
|
|
},
|
|
{
|
|
value: 'official_openai_embeddings',
|
|
label: 'Official OpenAI Embeddings',
|
|
config: {
|
|
advanced_routes: [
|
|
{
|
|
incoming_path: '/v1/embeddings',
|
|
upstream_path: 'https://api.openai.com/v1/embeddings',
|
|
converter: 'none',
|
|
auth: bearerHeaderAuth(),
|
|
},
|
|
],
|
|
advanced_fallback: { enabled: false },
|
|
},
|
|
},
|
|
{
|
|
value: 'official_openai_images',
|
|
label: 'Official OpenAI Images',
|
|
config: {
|
|
advanced_routes: [
|
|
{
|
|
incoming_path: '/v1/images/generations',
|
|
upstream_path: 'https://api.openai.com/v1/images/generations',
|
|
converter: 'none',
|
|
auth: bearerHeaderAuth(),
|
|
},
|
|
{
|
|
incoming_path: '/v1/images/edits',
|
|
upstream_path: 'https://api.openai.com/v1/images/edits',
|
|
converter: 'none',
|
|
auth: bearerHeaderAuth(),
|
|
},
|
|
],
|
|
advanced_fallback: { enabled: false },
|
|
},
|
|
},
|
|
{
|
|
value: 'official_claude_messages',
|
|
label: 'Official Claude Messages',
|
|
config: {
|
|
advanced_routes: [
|
|
{
|
|
incoming_path: '/v1/messages',
|
|
upstream_path: 'https://api.anthropic.com/v1/messages',
|
|
converter: 'none',
|
|
auth: apiKeyHeaderAuth(),
|
|
},
|
|
],
|
|
advanced_fallback: { enabled: false },
|
|
},
|
|
},
|
|
{
|
|
value: 'official_gemini_native',
|
|
label: 'Official Gemini Native',
|
|
config: {
|
|
advanced_routes: [
|
|
{
|
|
incoming_path: '/v1beta/models/{model}:generateContent',
|
|
upstream_path:
|
|
'https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent',
|
|
converter: 'none',
|
|
auth: geminiQueryAuth(),
|
|
},
|
|
{
|
|
incoming_path: '/v1beta/models/{model}:embedContent',
|
|
upstream_path:
|
|
'https://generativelanguage.googleapis.com/v1beta/models/{model}:embedContent',
|
|
converter: 'none',
|
|
auth: geminiQueryAuth(),
|
|
},
|
|
{
|
|
incoming_path: '/v1beta/models/{model}:batchEmbedContents',
|
|
upstream_path:
|
|
'https://generativelanguage.googleapis.com/v1beta/models/{model}:batchEmbedContents',
|
|
converter: 'none',
|
|
auth: geminiQueryAuth(),
|
|
},
|
|
],
|
|
advanced_fallback: { enabled: false },
|
|
},
|
|
},
|
|
{
|
|
value: 'official_gemini_from_openai_chat',
|
|
label: 'Official Gemini from OpenAI Chat',
|
|
config: {
|
|
advanced_routes: [
|
|
{
|
|
incoming_path: '/v1/chat/completions',
|
|
upstream_path:
|
|
'https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent',
|
|
converter: 'openai_chat_completions_to_gemini_generate_content',
|
|
auth: geminiQueryAuth(),
|
|
},
|
|
],
|
|
advanced_fallback: { enabled: false },
|
|
},
|
|
},
|
|
]
|
|
|
|
export function cloneAdvancedCustomConfig(
|
|
config: AdvancedCustomConfig
|
|
): AdvancedCustomConfig {
|
|
return JSON.parse(JSON.stringify(config)) as AdvancedCustomConfig
|
|
}
|
|
|
|
export function getAdvancedCustomTemplateConfig(
|
|
templateKey: string
|
|
): AdvancedCustomConfig {
|
|
const template =
|
|
ADVANCED_CUSTOM_TEMPLATE_OPTIONS.find(
|
|
(option) => option.value === templateKey
|
|
) || ADVANCED_CUSTOM_TEMPLATE_OPTIONS[0]
|
|
return cloneAdvancedCustomConfig(template.config)
|
|
}
|
|
|
|
export function createAdvancedCustomRoute(): AdvancedCustomRoute {
|
|
return {
|
|
incoming_path: '/v1/chat/completions',
|
|
upstream_path: '/v1/chat/completions',
|
|
converter: 'none',
|
|
}
|
|
}
|
|
|
|
export function createAdvancedCustomConfig(): AdvancedCustomConfig {
|
|
return {
|
|
advanced_routes: [createAdvancedCustomRoute()],
|
|
advanced_fallback: { enabled: false },
|
|
}
|
|
}
|
|
|
|
export function getAdvancedCustomUpstreamPathPlaceholder(
|
|
converter: AdvancedCustomConverter
|
|
): string {
|
|
if (converter === 'openai_chat_completions_to_gemini_generate_content') {
|
|
return '/v1beta/models/{model}:generateContent'
|
|
}
|
|
if (converter === 'openai_chat_completions_to_anthropic_messages') {
|
|
return '/v1/messages'
|
|
}
|
|
return '/v1/chat/completions'
|
|
}
|
|
|
|
export function getAdvancedCustomIncomingPathOptions(
|
|
converter: AdvancedCustomConverter
|
|
): AdvancedCustomIncomingPathOption[] {
|
|
return ADVANCED_CUSTOM_INCOMING_PATH_OPTIONS.filter((option) =>
|
|
isConverterPathAllowed(option.value, converter)
|
|
)
|
|
}
|
|
|
|
export function getDefaultAdvancedCustomIncomingPath(
|
|
converter: AdvancedCustomConverter
|
|
): string {
|
|
return (
|
|
getAdvancedCustomIncomingPathOptions(converter)[0]?.value ||
|
|
'/v1/chat/completions'
|
|
)
|
|
}
|
|
|
|
export function isAdvancedCustomIncomingPathAllowed(
|
|
incomingPath: string,
|
|
converter: AdvancedCustomConverter
|
|
): boolean {
|
|
return getAdvancedCustomIncomingPathOptions(converter).some(
|
|
(option) => option.value === incomingPath
|
|
)
|
|
}
|
|
|
|
export function getAdvancedCustomIncomingPathLabel(value: string): string {
|
|
return (
|
|
ADVANCED_CUSTOM_INCOMING_PATH_OPTIONS.find(
|
|
(option) => option.value === value
|
|
)?.label || value
|
|
)
|
|
}
|
|
|
|
export function parseAdvancedCustomConfig(
|
|
value: string | undefined
|
|
): AdvancedCustomConfig | null {
|
|
if (!value?.trim()) return null
|
|
try {
|
|
const parsed = JSON.parse(value)
|
|
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
return null
|
|
}
|
|
return normalizeAdvancedCustomConfig(parsed as AdvancedCustomConfig)
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
export function stringifyAdvancedCustomConfig(
|
|
config: AdvancedCustomConfig
|
|
): string {
|
|
return JSON.stringify(normalizeAdvancedCustomConfig(config), null, 2)
|
|
}
|
|
|
|
export function normalizeAdvancedCustomConfig(
|
|
config: AdvancedCustomConfig
|
|
): AdvancedCustomConfig {
|
|
const routes = Array.isArray(config.advanced_routes)
|
|
? config.advanced_routes.map(normalizeAdvancedCustomRoute)
|
|
: []
|
|
|
|
return {
|
|
advanced_routes: routes,
|
|
advanced_fallback: {
|
|
enabled: config.advanced_fallback?.enabled === true,
|
|
},
|
|
}
|
|
}
|
|
|
|
export function validateAdvancedCustomConfig(
|
|
config: AdvancedCustomConfig | null
|
|
): AdvancedCustomValidationError | null {
|
|
if (!config) {
|
|
return { message: 'Advanced custom configuration is required' }
|
|
}
|
|
|
|
const normalized = normalizeAdvancedCustomConfig(config)
|
|
const routes = normalized.advanced_routes || []
|
|
const fallbackEnabled = normalized.advanced_fallback?.enabled === true
|
|
if (routes.length === 0 && !fallbackEnabled) {
|
|
return {
|
|
message:
|
|
'Advanced custom configuration requires at least one route or fallback',
|
|
}
|
|
}
|
|
|
|
const seenPaths = new Set<string>()
|
|
for (let index = 0; index < routes.length; index += 1) {
|
|
const route = routes[index]
|
|
const incomingPath = route.incoming_path?.trim() || ''
|
|
const upstreamPath = getAdvancedCustomRouteUpstreamPath(route)
|
|
const converter = route.converter || 'none'
|
|
|
|
if (!incomingPath) {
|
|
return { routeIndex: index, message: 'Incoming path is required' }
|
|
}
|
|
if (!incomingPath.startsWith('/')) {
|
|
return { routeIndex: index, message: 'Incoming path must start with /' }
|
|
}
|
|
if (incomingPath.includes('?')) {
|
|
return {
|
|
routeIndex: index,
|
|
message: 'Incoming path must not include query',
|
|
}
|
|
}
|
|
if (seenPaths.has(incomingPath)) {
|
|
return { routeIndex: index, message: 'Incoming path must be unique' }
|
|
}
|
|
seenPaths.add(incomingPath)
|
|
|
|
if (!upstreamPath) {
|
|
return { routeIndex: index, message: 'Upstream path is required' }
|
|
}
|
|
if (!isFullHttpURLOrAbsolutePath(upstreamPath)) {
|
|
return {
|
|
routeIndex: index,
|
|
message: 'Upstream path must be a full URL or a path starting with /',
|
|
}
|
|
}
|
|
if (!isAdvancedCustomConverter(converter)) {
|
|
return { routeIndex: index, message: 'Converter is not registered' }
|
|
}
|
|
if (!isConverterPathAllowed(incomingPath, converter)) {
|
|
return {
|
|
routeIndex: index,
|
|
message: 'Converter does not match incoming path',
|
|
}
|
|
}
|
|
|
|
const authError = validateRouteAuth(route.auth)
|
|
if (authError) {
|
|
return { routeIndex: index, message: authError }
|
|
}
|
|
}
|
|
|
|
return null
|
|
}
|
|
|
|
export function advancedCustomConfigUsesRelativeUpstreamPath(
|
|
config: AdvancedCustomConfig | null
|
|
): boolean {
|
|
if (!config) return false
|
|
const normalized = normalizeAdvancedCustomConfig(config)
|
|
return (normalized.advanced_routes || []).some((route) =>
|
|
getAdvancedCustomRouteUpstreamPath(route).startsWith('/')
|
|
)
|
|
}
|
|
|
|
export function getAdvancedCustomStats(value: string | undefined): {
|
|
routeCount: number
|
|
fallbackEnabled: boolean
|
|
valid: boolean
|
|
} {
|
|
const config = parseAdvancedCustomConfig(value)
|
|
if (!config) {
|
|
return { routeCount: 0, fallbackEnabled: false, valid: false }
|
|
}
|
|
const normalized = normalizeAdvancedCustomConfig(config)
|
|
return {
|
|
routeCount: normalized.advanced_routes?.length || 0,
|
|
fallbackEnabled: normalized.advanced_fallback?.enabled === true,
|
|
valid: validateAdvancedCustomConfig(normalized) === null,
|
|
}
|
|
}
|
|
|
|
export function getAdvancedCustomAuthMode(
|
|
route: AdvancedCustomRoute
|
|
): AdvancedCustomAuthMode {
|
|
return route.auth?.type || 'default'
|
|
}
|
|
|
|
export function buildAdvancedCustomAuth(
|
|
mode: AdvancedCustomAuthMode,
|
|
previousAuth: AdvancedCustomRouteAuth | undefined
|
|
): AdvancedCustomRouteAuth | undefined {
|
|
if (mode === 'default') return undefined
|
|
if (mode === 'none') return { type: 'none' }
|
|
if (mode === 'header') {
|
|
return {
|
|
type: 'header',
|
|
name: previousAuth?.name || 'Authorization',
|
|
value: previousAuth?.value || 'Bearer {api_key}',
|
|
}
|
|
}
|
|
return {
|
|
type: 'query',
|
|
name: previousAuth?.name || 'api_key',
|
|
value: previousAuth?.value || '{api_key}',
|
|
}
|
|
}
|
|
|
|
function normalizeAdvancedCustomRoute(
|
|
route: AdvancedCustomRoute
|
|
): AdvancedCustomRoute {
|
|
const nextRoute: AdvancedCustomRoute = {
|
|
incoming_path: route.incoming_path || '',
|
|
upstream_path: getAdvancedCustomRouteUpstreamPath(route),
|
|
converter: route.converter || 'none',
|
|
}
|
|
if (route.auth) {
|
|
nextRoute.auth = {
|
|
type: route.auth.type,
|
|
name: route.auth.name || '',
|
|
value: route.auth.value || '',
|
|
}
|
|
}
|
|
return nextRoute
|
|
}
|
|
|
|
function getAdvancedCustomRouteUpstreamPath(route: AdvancedCustomRoute): string {
|
|
return (route.upstream_path || '').trim()
|
|
}
|
|
|
|
function isFullHttpURLOrAbsolutePath(value: string): boolean {
|
|
if (value.startsWith('/')) return !value.startsWith('//')
|
|
|
|
try {
|
|
const parsed = new URL(value)
|
|
return (
|
|
Boolean(parsed.host) &&
|
|
(parsed.protocol === 'http:' || parsed.protocol === 'https:')
|
|
)
|
|
} catch {
|
|
return false
|
|
}
|
|
}
|
|
|
|
function isAdvancedCustomConverter(
|
|
value: string
|
|
): value is AdvancedCustomConverter {
|
|
return ADVANCED_CUSTOM_CONVERTER_OPTIONS.some(
|
|
(option) => option.value === value
|
|
)
|
|
}
|
|
|
|
function isConverterPathAllowed(
|
|
incomingPath: string,
|
|
converter: AdvancedCustomConverter
|
|
): boolean {
|
|
if (converter === 'none') return true
|
|
if (converter === 'anthropic_messages_to_openai_chat_completions') {
|
|
return incomingPath === '/v1/messages'
|
|
}
|
|
if (
|
|
converter === 'openai_chat_completions_to_anthropic_messages' ||
|
|
converter === 'openai_chat_completions_to_openai_responses' ||
|
|
converter === 'openai_chat_completions_to_gemini_generate_content'
|
|
) {
|
|
return incomingPath === '/v1/chat/completions'
|
|
}
|
|
return (
|
|
incomingPath.includes(':generateContent') ||
|
|
incomingPath.includes(':streamGenerateContent')
|
|
)
|
|
}
|
|
|
|
function validateRouteAuth(
|
|
auth: AdvancedCustomRouteAuth | undefined
|
|
): string | null {
|
|
if (!auth) return null
|
|
if (auth.type === 'none') return null
|
|
if (auth.type !== 'header' && auth.type !== 'query') {
|
|
return 'Auth type is invalid'
|
|
}
|
|
if (!auth.name?.trim()) {
|
|
return 'Auth name is required'
|
|
}
|
|
if (!auth.value?.trim()) {
|
|
return 'Auth value is required'
|
|
}
|
|
return null
|
|
}
|