feat: support upstream model fetch for advanced custom channels (#5971)

* feat: support upstream model fetch for advanced custom channels

* fix: add advanced custom routes as separate groups

* fix: select advanced custom route entry before adding

---------

Co-authored-by: CaIon <i@caion.me>
This commit is contained in:
Seefs
2026-07-18 13:39:53 +08:00
committed by GitHub
co-authored by CaIon
parent 57746fc972
commit a6cf42c0f1
24 changed files with 1324 additions and 135 deletions
+53 -4
View File
@@ -193,6 +193,51 @@ func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) {
return a.routeURL(info)
}
func (a *Adaptor) BuildModelListRequest(info *relaycommon.RelayInfo) (string, http.Header, error) {
if info == nil {
return "", nil, errors.New("missing relay info")
}
config := info.ChannelOtherSettings.AdvancedCustom
if config == nil {
return "", nil, errors.New("advanced_custom is required")
}
if err := config.Validate(); err != nil {
return "", nil, err
}
route, ok := config.ModelListRoute()
if !ok {
return "", nil, errors.New("advanced custom channel does not configure a /v1/models route")
}
converter := strings.TrimSpace(route.Converter)
if converter == "" {
converter = relayconvert.ConverterNone
}
if converter != relayconvert.ConverterNone {
return "", nil, fmt.Errorf("converter %q does not support model list requests", converter)
}
requestURL, err := buildRouteURL(route, converter, info)
if err != nil {
return "", nil, err
}
header := http.Header{}
auth := route.Auth
if auth == nil {
header.Set("Authorization", "Bearer "+info.ApiKey)
return requestURL, header, nil
}
switch strings.TrimSpace(auth.Type) {
case dto.AdvancedCustomAuthTypeNone, dto.AdvancedCustomAuthTypeQuery:
case dto.AdvancedCustomAuthTypeHeader:
header.Set(strings.TrimSpace(auth.Name), applyAuthTemplate(auth.Value, info.ApiKey))
default:
return "", nil, fmt.Errorf("invalid advanced custom auth type: %s", auth.Type)
}
return requestURL, header, nil
}
func (a *Adaptor) SetupRequestHeader(c *gin.Context, header *http.Header, info *relaycommon.RelayInfo) error {
if err := a.resolve(c, info); err != nil {
return err
@@ -343,11 +388,15 @@ func incomingRequestPath(c *gin.Context, info *relaycommon.RelayInfo) string {
}
func (a *Adaptor) routeURL(info *relaycommon.RelayInfo) (string, error) {
parsedURL, err := resolveUpstreamTargetURL(applyUpstreamPathTemplate(strings.TrimSpace(a.route.UpstreamPath), info), info)
return buildRouteURL(a.route, a.converter, info)
}
func buildRouteURL(route dto.AdvancedCustomRoute, converter string, info *relaycommon.RelayInfo) (string, error) {
parsedURL, err := resolveUpstreamTargetURL(applyUpstreamPathTemplate(strings.TrimSpace(route.UpstreamPath), info), info)
if err != nil {
return "", err
}
if shouldUseGeminiStreamURL(a.converter, info) {
if shouldUseGeminiStreamURL(converter, info) {
useGeminiStreamGenerateContentURL(parsedURL)
}
if info != nil && info.RelayMode == relayconstant.RelayModeRealtime {
@@ -358,9 +407,9 @@ func (a *Adaptor) routeURL(info *relaycommon.RelayInfo) (string, error) {
parsedURL.Scheme = "ws"
}
}
if a.route.Auth != nil && strings.TrimSpace(a.route.Auth.Type) == dto.AdvancedCustomAuthTypeQuery {
if route.Auth != nil && strings.TrimSpace(route.Auth.Type) == dto.AdvancedCustomAuthTypeQuery {
query := parsedURL.Query()
query.Set(strings.TrimSpace(a.route.Auth.Name), applyAuthTemplate(a.route.Auth.Value, info.ApiKey))
query.Set(strings.TrimSpace(route.Auth.Name), applyAuthTemplate(route.Auth.Value, info.ApiKey))
parsedURL.RawQuery = query.Encode()
}
return parsedURL.String(), nil
@@ -284,6 +284,144 @@ func TestAdaptorMatchesGeminiIncomingPathTemplate(t *testing.T) {
}
}
func TestAdaptorBuildModelListRequestUsesConfiguredRouteAuth(t *testing.T) {
adaptor := &Adaptor{}
info := advancedCustomRelayInfo(&dto.AdvancedCustomConfig{
Routes: []dto.AdvancedCustomRoute{
{
IncomingPath: "/v1/models",
UpstreamPath: "/provider/models",
Converter: relayconvert.ConverterNone,
Auth: &dto.AdvancedCustomRouteAuth{
Type: dto.AdvancedCustomAuthTypeHeader,
Name: "x-api-key",
Value: "token {api_key}",
},
},
},
})
info.RequestURLPath = "/v1/models"
requestURL, header, err := adaptor.BuildModelListRequest(info)
require.NoError(t, err)
parsedURL, err := url.Parse(requestURL)
require.NoError(t, err)
assert.Equal(t, "fallback.example", parsedURL.Host)
assert.Equal(t, "/provider/models", parsedURL.Path)
assert.Equal(t, "token sk-test", header.Get("x-api-key"))
assert.Empty(t, header.Get("Authorization"))
}
func TestAdaptorBuildModelListRequestUsesConfiguredQueryAuth(t *testing.T) {
adaptor := &Adaptor{}
info := advancedCustomRelayInfo(&dto.AdvancedCustomConfig{
Routes: []dto.AdvancedCustomRoute{
{
IncomingPath: "/v1/models",
UpstreamPath: "https://upstream.example/v1/models?existing=1",
Converter: relayconvert.ConverterNone,
Auth: &dto.AdvancedCustomRouteAuth{
Type: dto.AdvancedCustomAuthTypeQuery,
Name: "key",
Value: "{api_key}",
},
},
},
})
info.RequestURLPath = "/v1/models"
requestURL, header, err := adaptor.BuildModelListRequest(info)
require.NoError(t, err)
parsedURL, err := url.Parse(requestURL)
require.NoError(t, err)
assert.Equal(t, "upstream.example", parsedURL.Host)
assert.Equal(t, "/v1/models", parsedURL.Path)
assert.Equal(t, "1", parsedURL.Query().Get("existing"))
assert.Equal(t, "sk-test", parsedURL.Query().Get("key"))
assert.Empty(t, header.Get("Authorization"))
}
func TestAdaptorBuildModelListRequestDefaultAndNoAuth(t *testing.T) {
tests := []struct {
name string
auth *dto.AdvancedCustomRouteAuth
wantAuthorization string
}{
{
name: "default bearer",
wantAuthorization: "Bearer sk-test",
},
{
name: "no authentication",
auth: &dto.AdvancedCustomRouteAuth{
Type: dto.AdvancedCustomAuthTypeNone,
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
info := advancedCustomRelayInfo(&dto.AdvancedCustomConfig{
Routes: []dto.AdvancedCustomRoute{
{
IncomingPath: dto.AdvancedCustomModelListPath,
UpstreamPath: "/provider/models",
Auth: tt.auth,
},
},
})
info.RequestURLPath = "/unrelated/path"
requestURL, header, err := (&Adaptor{}).BuildModelListRequest(info)
require.NoError(t, err)
assert.Equal(t, "https://fallback.example/provider/models", requestURL)
assert.Equal(t, tt.wantAuthorization, header.Get("Authorization"))
})
}
}
func TestAdaptorBuildModelListRequestDoesNotReuseRelayRoute(t *testing.T) {
adaptor := &Adaptor{}
info := advancedCustomRelayInfo(&dto.AdvancedCustomConfig{
Routes: []dto.AdvancedCustomRoute{
{
IncomingPath: "/v1/chat/completions",
UpstreamPath: "/chat",
},
{
IncomingPath: dto.AdvancedCustomModelListPath,
UpstreamPath: "/provider/models",
},
},
})
chatURL, err := adaptor.GetRequestURL(info)
require.NoError(t, err)
assert.Equal(t, "https://fallback.example/chat", chatURL)
modelURL, header, err := adaptor.BuildModelListRequest(info)
require.NoError(t, err)
assert.Equal(t, "https://fallback.example/provider/models", modelURL)
assert.Equal(t, "Bearer sk-test", header.Get("Authorization"))
}
func TestAdaptorBuildModelListRequestRequiresConfiguredRoute(t *testing.T) {
info := advancedCustomRelayInfo(&dto.AdvancedCustomConfig{
Routes: []dto.AdvancedCustomRoute{
{
IncomingPath: "/v1/chat/completions",
UpstreamPath: "/v1/chat/completions",
},
},
})
_, _, err := (&Adaptor{}).BuildModelListRequest(info)
require.Error(t, err)
assert.Contains(t, err.Error(), "does not configure a /v1/models route")
}
func TestAdaptorConvertsResponsesRequestToOpenAIChatUpstream(t *testing.T) {
adaptor := &Adaptor{}
info := advancedCustomRelayInfo(&dto.AdvancedCustomConfig{