Commit 3f2c0aed by Seefs Committed by GitHub

feat: advanced custom channel (#5590)

parent 21d4d18d
......@@ -75,6 +75,8 @@ func ChannelType2APIType(channelType int) (int, bool) {
apiType = constant.APITypeReplicate
case constant.ChannelTypeCodex:
apiType = constant.APITypeCodex
case constant.ChannelTypeAdvancedCustom:
apiType = constant.APITypeAdvancedCustom
}
if apiType == -1 {
return constant.APITypeOpenAI, false
......
......@@ -36,5 +36,6 @@ const (
APITypeMiniMax
APITypeReplicate
APITypeCodex
APITypeAdvancedCustom
APITypeDummy // this one is only for count, do not add any channel after this
)
......@@ -55,6 +55,7 @@ const (
ChannelTypeSora = 55
ChannelTypeReplicate = 56
ChannelTypeCodex = 57
ChannelTypeAdvancedCustom = 58
ChannelTypeDummy // this one is only for count, do not add any channel after this
)
......@@ -118,6 +119,7 @@ var ChannelBaseURLs = []string{
"https://api.openai.com", //55
"https://api.replicate.com", //56
"https://chatgpt.com", //57
"", //58
}
var ChannelTypeNames = map[int]string{
......@@ -175,6 +177,7 @@ var ChannelTypeNames = map[int]string{
ChannelTypeSora: "Sora",
ChannelTypeReplicate: "Replicate",
ChannelTypeCodex: "ChatGPT Subscription (Codex)",
ChannelTypeAdvancedCustom: "Advanced Custom",
}
func GetChannelTypeName(channelType int) string {
......
package dto
import (
"fmt"
"net/url"
"strings"
)
type ChannelSettings struct {
ForceFormat bool `json:"force_format,omitempty"`
ThinkingToContent bool `json:"thinking_to_content,omitempty"`
......@@ -41,6 +47,7 @@ type ChannelOtherSettings struct {
UpstreamModelUpdateLastDetectedModels []string `json:"upstream_model_update_last_detected_models,omitempty"` // 上次检测到的可加入模型
UpstreamModelUpdateLastRemovedModels []string `json:"upstream_model_update_last_removed_models,omitempty"` // 上次检测到的可删除模型
UpstreamModelUpdateIgnoredModels []string `json:"upstream_model_update_ignored_models,omitempty"` // 手动忽略的模型
AdvancedCustom *AdvancedCustomConfig `json:"advanced_custom,omitempty"`
}
func (s *ChannelOtherSettings) IsOpenRouterEnterprise() bool {
......@@ -49,3 +56,168 @@ func (s *ChannelOtherSettings) IsOpenRouterEnterprise() bool {
}
return *s.OpenRouterEnterprise
}
const (
AdvancedCustomConverterNone = "none"
AdvancedCustomConverterAnthropicMessagesToOpenAIChatCompletions = "anthropic_messages_to_openai_chat_completions"
AdvancedCustomConverterOpenAIChatCompletionsToAnthropicMessages = "openai_chat_completions_to_anthropic_messages"
AdvancedCustomConverterOpenAIChatCompletionsToOpenAIResponses = "openai_chat_completions_to_openai_responses"
AdvancedCustomConverterGeminiGenerateContentToOpenAIChatCompletions = "gemini_generate_content_to_openai_chat_completions"
AdvancedCustomConverterOpenAIChatCompletionsToGeminiGenerateContent = "openai_chat_completions_to_gemini_generate_content"
)
const (
AdvancedCustomAuthTypeNone = "none"
AdvancedCustomAuthTypeHeader = "header"
AdvancedCustomAuthTypeQuery = "query"
)
type AdvancedCustomConfig struct {
Routes []AdvancedCustomRoute `json:"advanced_routes,omitempty"`
Fallback AdvancedCustomFallback `json:"advanced_fallback,omitempty"`
}
type AdvancedCustomRoute struct {
IncomingPath string `json:"incoming_path,omitempty"`
UpstreamPath string `json:"upstream_path,omitempty"`
Converter string `json:"converter,omitempty"`
Auth *AdvancedCustomRouteAuth `json:"auth,omitempty"`
}
type AdvancedCustomFallback struct {
Enabled bool `json:"enabled,omitempty"`
}
type AdvancedCustomRouteAuth struct {
Type string `json:"type,omitempty"`
Name string `json:"name,omitempty"`
Value string `json:"value,omitempty"`
}
func IsAdvancedCustomConverterAllowed(converter string) bool {
switch converter {
case AdvancedCustomConverterNone,
AdvancedCustomConverterAnthropicMessagesToOpenAIChatCompletions,
AdvancedCustomConverterOpenAIChatCompletionsToAnthropicMessages,
AdvancedCustomConverterOpenAIChatCompletionsToOpenAIResponses,
AdvancedCustomConverterGeminiGenerateContentToOpenAIChatCompletions,
AdvancedCustomConverterOpenAIChatCompletionsToGeminiGenerateContent:
return true
default:
return false
}
}
func (c *AdvancedCustomConfig) Validate() error {
if c == nil {
return fmt.Errorf("advanced_custom is required")
}
if len(c.Routes) == 0 && !c.Fallback.Enabled {
return fmt.Errorf("advanced_custom requires at least one route or enabled fallback")
}
seenPaths := make(map[string]struct{}, len(c.Routes))
for i := range c.Routes {
route := c.Routes[i]
route.IncomingPath = strings.TrimSpace(route.IncomingPath)
upstreamPath := strings.TrimSpace(route.UpstreamPath)
route.Converter = strings.TrimSpace(route.Converter)
if route.Converter == "" {
route.Converter = AdvancedCustomConverterNone
}
if route.IncomingPath == "" {
return fmt.Errorf("advanced_custom.advanced_routes[%d].incoming_path is required", i)
}
if !strings.HasPrefix(route.IncomingPath, "/") {
return fmt.Errorf("advanced_custom.advanced_routes[%d].incoming_path must start with /", i)
}
if strings.Contains(route.IncomingPath, "?") {
return fmt.Errorf("advanced_custom.advanced_routes[%d].incoming_path must not include query", i)
}
if _, exists := seenPaths[route.IncomingPath]; exists {
return fmt.Errorf("advanced_custom.advanced_routes[%d].incoming_path must be unique: %s", i, route.IncomingPath)
}
seenPaths[route.IncomingPath] = struct{}{}
if upstreamPath == "" {
return fmt.Errorf("advanced_custom.advanced_routes[%d].upstream_path is required", i)
}
if err := validateAdvancedCustomUpstreamTarget(i, upstreamPath); err != nil {
return err
}
if !IsAdvancedCustomConverterAllowed(route.Converter) {
return fmt.Errorf("advanced_custom.advanced_routes[%d].converter is not registered: %s", i, route.Converter)
}
if err := validateAdvancedCustomConverterPath(i, route.IncomingPath, route.Converter); err != nil {
return err
}
if err := validateAdvancedCustomRouteAuth(i, route.Auth); err != nil {
return err
}
}
return nil
}
func validateAdvancedCustomUpstreamTarget(index int, upstreamPath string) error {
if strings.HasPrefix(upstreamPath, "/") {
if strings.HasPrefix(upstreamPath, "//") {
return fmt.Errorf("advanced_custom.advanced_routes[%d].upstream_path must be a full URL or a path starting with /", index)
}
return nil
}
parsedURL, err := url.Parse(upstreamPath)
if err != nil || parsedURL.Scheme == "" || parsedURL.Host == "" {
return fmt.Errorf("advanced_custom.advanced_routes[%d].upstream_path must be a full URL or a path starting with /", index)
}
if !strings.EqualFold(parsedURL.Scheme, "http") && !strings.EqualFold(parsedURL.Scheme, "https") {
return fmt.Errorf("advanced_custom.advanced_routes[%d].upstream_path must use http or https", index)
}
return nil
}
func validateAdvancedCustomConverterPath(index int, incomingPath string, converter string) error {
switch converter {
case AdvancedCustomConverterNone:
return nil
case AdvancedCustomConverterAnthropicMessagesToOpenAIChatCompletions:
if incomingPath == "/v1/messages" {
return nil
}
case AdvancedCustomConverterOpenAIChatCompletionsToAnthropicMessages,
AdvancedCustomConverterOpenAIChatCompletionsToOpenAIResponses,
AdvancedCustomConverterOpenAIChatCompletionsToGeminiGenerateContent:
if incomingPath == "/v1/chat/completions" {
return nil
}
case AdvancedCustomConverterGeminiGenerateContentToOpenAIChatCompletions:
if strings.Contains(incomingPath, ":generateContent") || strings.Contains(incomingPath, ":streamGenerateContent") {
return nil
}
}
return fmt.Errorf("advanced_custom.advanced_routes[%d].converter does not match incoming_path: %s", index, converter)
}
func validateAdvancedCustomRouteAuth(index int, auth *AdvancedCustomRouteAuth) error {
if auth == nil {
return nil
}
authType := strings.TrimSpace(auth.Type)
switch authType {
case AdvancedCustomAuthTypeNone:
return nil
case AdvancedCustomAuthTypeHeader, AdvancedCustomAuthTypeQuery:
if strings.TrimSpace(auth.Name) == "" {
return fmt.Errorf("advanced_custom.advanced_routes[%d].auth.name is required", index)
}
if strings.TrimSpace(auth.Value) == "" {
return fmt.Errorf("advanced_custom.advanced_routes[%d].auth.value is required", index)
}
return nil
default:
return fmt.Errorf("advanced_custom.advanced_routes[%d].auth.type is invalid: %s", index, auth.Type)
}
}
......@@ -945,6 +945,26 @@ func (channel *Channel) ValidateSettings() error {
return err
}
}
channelOtherSettings := &dto.ChannelOtherSettings{}
if channel.OtherSettings != "" {
err := common.UnmarshalJsonStr(channel.OtherSettings, channelOtherSettings)
if err != nil {
return err
}
}
if channel.Type == constant.ChannelTypeAdvancedCustom {
if channelOtherSettings.AdvancedCustom == nil {
return fmt.Errorf("advanced_custom is required")
}
if channelOtherSettings.AdvancedCustom.Fallback.Enabled && (channel.BaseURL == nil || strings.TrimSpace(*channel.BaseURL) == "") {
return fmt.Errorf("base_url is required when advanced_custom advanced_fallback is enabled")
}
}
if channelOtherSettings.AdvancedCustom != nil {
if err := channelOtherSettings.AdvancedCustom.Validate(); err != nil {
return err
}
}
return nil
}
......
......@@ -336,6 +336,7 @@ var streamSupportedChannels = map[int]bool{
constant.ChannelTypeMoonshot: true,
constant.ChannelTypeMiniMax: true,
constant.ChannelTypeSiliconFlow: true,
constant.ChannelTypeAdvancedCustom: true,
}
func GenRelayInfoWs(c *gin.Context, ws *websocket.Conn) *RelayInfo {
......
......@@ -5,6 +5,7 @@ import (
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/relay/channel"
"github.com/QuantumNous/new-api/relay/channel/advancedcustom"
"github.com/QuantumNous/new-api/relay/channel/ali"
"github.com/QuantumNous/new-api/relay/channel/aws"
"github.com/QuantumNous/new-api/relay/channel/baidu"
......@@ -120,6 +121,8 @@ func GetAdaptor(apiType int) channel.Adaptor {
return &replicate.Adaptor{}
case constant.APITypeCodex:
return &codex.Adaptor{}
case constant.APITypeAdvancedCustom:
return &advancedcustom.Adaptor{}
}
return nil
}
......
......@@ -159,7 +159,10 @@ function SelectItem({
)}
{...props}
>
<SelectPrimitive.ItemText className='flex flex-1 shrink-0 gap-2 whitespace-nowrap'>
<SelectPrimitive.ItemText
data-slot='select-item-text'
className='flex flex-1 shrink-0 gap-2 whitespace-nowrap'
>
{children}
</SelectPrimitive.ItemText>
<SelectPrimitive.ItemIndicator
......
......@@ -125,8 +125,10 @@ import {
import { useChannelMutateForm } from '../../hooks/use-channel-mutate-form'
import {
CHANNEL_FORM_DEFAULT_VALUES,
CHANNEL_TYPE_ADVANCED_CUSTOM,
channelFormSchema,
channelsQueryKeys,
getAdvancedCustomStats,
transformChannelToFormDefaults,
type ChannelFormValues,
deduplicateKeys,
......@@ -147,6 +149,7 @@ import {
} from '../../lib/status-code-risk-guard'
import type { Channel } from '../../types'
import { useChannels } from '../channels-provider'
import { AdvancedCustomEditorDialog } from '../dialogs/advanced-custom-editor-dialog'
import { FetchModelsDialog } from '../dialogs/fetch-models-dialog'
import {
MissingModelsConfirmationDialog,
......@@ -205,6 +208,7 @@ function hasAdvancedSettingsValues(values: ChannelFormValues): boolean {
return Boolean(
values.param_override?.trim() ||
values.header_override?.trim() ||
values.advanced_custom?.trim() ||
values.status_code_mapping?.trim() ||
values.tag?.trim() ||
values.remark?.trim() ||
......@@ -298,6 +302,8 @@ export function ChannelMutateDrawer({
>(null)
const [advancedSettingsOpen, setAdvancedSettingsOpen] = useState(false)
const [paramOverrideEditorOpen, setParamOverrideEditorOpen] = useState(false)
const [advancedCustomEditorOpen, setAdvancedCustomEditorOpen] =
useState(false)
const isEditing = Boolean(currentRow)
const channelId = currentRow?.id ?? null
......@@ -375,6 +381,7 @@ export function ChannelMutateDrawer({
'upstream_model_update_check_enabled'
)
const currentSettings = form.watch('settings')
const currentAdvancedCustom = form.watch('advanced_custom')
const {
unlocked: doubaoApiEditUnlocked,
handleClick: handleApiConfigSecretClick,
......@@ -407,6 +414,11 @@ export function ChannelMutateDrawer({
[supportsMultiKeyAddMode]
)
const advancedCustomStats = useMemo(
() => getAdvancedCustomStats(currentAdvancedCustom),
[currentAdvancedCustom]
)
// Get all models list
const allModelsList = useMemo(
() => allModelsData?.data?.map((model) => model.id).filter(Boolean) || [],
......@@ -1817,6 +1829,62 @@ export function ChannelMutateDrawer({
/>
)}
{currentType === CHANNEL_TYPE_ADVANCED_CUSTOM && (
<FormField
control={form.control}
name='advanced_custom'
render={({ field }) => (
<FormItem className='space-y-3 border-y py-4'>
<div className='flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between'>
<div className='space-y-2'>
<FormLabel>
{t('Advanced Custom Routes')}
</FormLabel>
<div className='flex flex-wrap gap-2'>
<Badge variant='secondary'>
{t('Routes')}:{' '}
{advancedCustomStats.routeCount}
</Badge>
<Badge
variant={
advancedCustomStats.fallbackEnabled
? 'default'
: 'outline'
}
>
{t('Fallback')}:{' '}
{advancedCustomStats.fallbackEnabled
? t('Enabled')
: t('Disabled')}
</Badge>
{!advancedCustomStats.valid && (
<Badge variant='destructive'>
{t('Incomplete')}
</Badge>
)}
</div>
</div>
<Button
type='button'
variant='outline'
size='sm'
onClick={() =>
setAdvancedCustomEditorOpen(true)
}
>
<Route className='mr-2 h-4 w-4' />
{t('Configure routes')}
</Button>
</div>
<FormControl>
<input type='hidden' {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
<ChannelAuthSection>
{!isEditing && (
<FormField
......@@ -3423,6 +3491,20 @@ export function ChannelMutateDrawer({
/>
)}
{advancedCustomEditorOpen && (
<AdvancedCustomEditorDialog
open={advancedCustomEditorOpen}
value={form.watch('advanced_custom') || ''}
onOpenChange={setAdvancedCustomEditorOpen}
onSave={(nextValue) => {
form.setValue('advanced_custom', nextValue, {
shouldDirty: true,
shouldValidate: true,
})
}}
/>
)}
{/* Fetch Models Dialog */}
<FetchModelsDialog
open={fetchModelsDialogOpen}
......
......@@ -76,12 +76,13 @@ export const CHANNEL_TYPES = {
55: 'Sora',
56: 'Replicate',
57: 'ChatGPT Subscription (Codex)',
58: 'Advanced Custom',
} as const
const CHANNEL_TYPE_DISPLAY_ORDER: number[] = [
1, 14, 33, 24, 43, 3, 41, 48, 42, 34, 20, 4, 40, 27, 25, 17, 26, 15, 46, 23,
18, 45, 31, 35, 49, 19, 47, 37, 38, 39, 11, 8, 57, 22, 21, 44, 2, 5, 36, 50,
51, 52, 53, 54, 55, 56,
18, 45, 31, 35, 49, 19, 47, 37, 38, 39, 11, 8, 58, 57, 22, 21, 44, 2, 5, 36,
50, 51, 52, 53, 54, 55, 56,
]
export const CHANNEL_TYPE_OPTIONS: { value: number; label: string }[] = (() => {
......
......@@ -33,6 +33,7 @@ const ADVANCED_SETTINGS_FIELDS = new Set<FieldPath<ChannelFormValues>>([
'param_override',
'header_override',
'status_code_mapping',
'advanced_custom',
'force_format',
'thinking_to_content',
'pass_through_body_enabled',
......
......@@ -23,6 +23,13 @@ import {
MODEL_FETCHABLE_TYPES,
} from '../constants'
import type { Channel } from '../types'
import {
CHANNEL_TYPE_ADVANCED_CUSTOM,
advancedCustomConfigUsesRelativeUpstreamPath,
parseAdvancedCustomConfig,
stringifyAdvancedCustomConfig,
validateAdvancedCustomConfig,
} from './advanced-custom'
// ============================================================================
// Form Validation Schema
......@@ -169,6 +176,7 @@ export const channelFormSchema = z
.string()
.optional()
.refine(isOptionalJsonObject, ERROR_MESSAGES.INVALID_JSON),
advanced_custom: z.string().optional(),
other: z.string().optional(),
// Multi-key options (not sent to backend directly)
multi_key_mode: z.enum(['single', 'batch', 'multi_to_single']).optional(),
......@@ -209,6 +217,37 @@ export const channelFormSchema = z
)
}
if (data.type === CHANNEL_TYPE_ADVANCED_CUSTOM) {
const advancedCustomConfig = parseAdvancedCustomConfig(
data.advanced_custom
)
const advancedCustomError =
validateAdvancedCustomConfig(advancedCustomConfig)
if (advancedCustomError) {
addRequiredIssue(ctx, 'advanced_custom', advancedCustomError.message)
}
if (
advancedCustomConfig?.advanced_fallback?.enabled === true &&
!data.base_url?.trim()
) {
addRequiredIssue(
ctx,
'base_url',
'Base URL is required when fallback is enabled'
)
}
if (
advancedCustomConfigUsesRelativeUpstreamPath(advancedCustomConfig) &&
!data.base_url?.trim()
) {
addRequiredIssue(
ctx,
'base_url',
'Base URL is required when an advanced route uses an upstream path'
)
}
}
if ([3, 18, 21, 39, 41, 49].includes(data.type) && !data.other?.trim()) {
addRequiredIssue(
ctx,
......@@ -316,6 +355,7 @@ export const CHANNEL_FORM_DEFAULT_VALUES: ChannelFormValues = {
upstream_model_update_check_enabled: false,
upstream_model_update_auto_sync_enabled: false,
upstream_model_update_ignored_models: '',
advanced_custom: '',
}
// ============================================================================
......@@ -370,6 +410,7 @@ export function transformChannelToFormDefaults(
let upstreamModelUpdateCheckEnabled = false
let upstreamModelUpdateAutoSyncEnabled = false
let upstreamModelUpdateIgnoredModels = ''
let advancedCustom = ''
if (channel.settings) {
try {
......@@ -394,6 +435,9 @@ export function transformChannelToFormDefaults(
)
? parsed.upstream_model_update_ignored_models.join(',')
: ''
if (parsed.advanced_custom) {
advancedCustom = stringifyAdvancedCustomConfig(parsed.advanced_custom)
}
} catch (error) {
// eslint-disable-next-line no-console
console.error('Failed to parse channel settings:', error)
......@@ -443,6 +487,7 @@ export function transformChannelToFormDefaults(
upstream_model_update_check_enabled: upstreamModelUpdateCheckEnabled,
upstream_model_update_auto_sync_enabled: upstreamModelUpdateAutoSyncEnabled,
upstream_model_update_ignored_models: upstreamModelUpdateIgnoredModels,
advanced_custom: advancedCustom,
}
}
......@@ -567,6 +612,17 @@ function buildSettingsJSON(formData: ChannelFormValues): string {
}
}
if (formData.type === CHANNEL_TYPE_ADVANCED_CUSTOM) {
const advancedCustomConfig = parseAdvancedCustomConfig(
formData.advanced_custom
)
if (advancedCustomConfig) {
settingsObj.advanced_custom = advancedCustomConfig
}
} else if ('advanced_custom' in settingsObj) {
delete settingsObj.advanced_custom
}
return JSON.stringify(settingsObj)
}
......
......@@ -134,6 +134,16 @@ export const CHANNEL_TYPE_CONFIGS: Record<number, ChannelTypeConfig> = {
baseUrl: 'Default: https://api.replicate.com',
},
},
58: {
id: 58,
name: CHANNEL_TYPES[58],
icon: 'openai',
hints: {
baseUrl: 'Fallback base URL',
key: 'Used by route auth templates',
models: 'Models exposed by this channel',
},
},
}
/**
......
......@@ -51,6 +51,7 @@ export function getChannelTypeIcon(type: number): string {
6: 'OpenAI', // OpenAIMax
7: 'OpenAI', // OhMyGPT
8: 'OpenAI', // Custom
58: 'OpenAI', // Advanced Custom
3: 'Azure', // Azure
// Anthropic
......
......@@ -18,6 +18,7 @@ For commercial licensing, please contact support@quantumnous.com
*/
// Re-export all library functions
export * from './channel-actions'
export * from './advanced-custom'
export * from './channel-form-errors'
export * from './channel-form'
export * from './channel-type-config'
......
......@@ -105,8 +105,41 @@ export interface ChannelOtherSettings {
upstream_model_update_ignored_models?: string[]
upstream_model_update_last_check_time?: number
upstream_model_update_last_detected_models?: string[]
advanced_custom?: AdvancedCustomConfig
}
export interface AdvancedCustomConfig {
advanced_routes?: AdvancedCustomRoute[]
advanced_fallback?: AdvancedCustomFallback
}
export interface AdvancedCustomRoute {
incoming_path?: string
upstream_path?: string
converter?: AdvancedCustomConverter
auth?: AdvancedCustomRouteAuth
}
export interface AdvancedCustomFallback {
enabled?: boolean
}
export interface AdvancedCustomRouteAuth {
type?: AdvancedCustomAuthType
name?: string
value?: string
}
export type AdvancedCustomConverter =
| 'none'
| 'anthropic_messages_to_openai_chat_completions'
| 'openai_chat_completions_to_anthropic_messages'
| 'openai_chat_completions_to_openai_responses'
| 'gemini_generate_content_to_openai_chat_completions'
| 'openai_chat_completions_to_gemini_generate_content'
export type AdvancedCustomAuthType = 'none' | 'header' | 'query'
// ============================================================================
// API Response Types
// ============================================================================
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or sign in to comment