feat(channel): 增加 Sub2API 渠道 (#567)

This commit is contained in:
tbphp authored and GitHub committed 2026-09-04 22:55:55 +08:00
1 parent c4cefc0655
commit ae59a0ae85
18 files changed
+200 -1

No files matched your search

+1
View File
@@ -31,6 +31,7 @@ func builtInModules() []spec.Module {
modules.GPTLoad(),
modules.NewAPI(),
modules.CLIProxyAPI(),
modules.Sub2API(),
modules.OpenAICompatible(),
}
}
+1
View File
@@ -34,6 +34,7 @@ const (
GPTLoad = spec.GPTLoad
NewAPI = spec.NewAPI
CLIProxyAPI = spec.CLIProxyAPI
Sub2API = spec.Sub2API
OpenAICompatible = spec.OpenAICompatible
DeepSeek = spec.DeepSeek
MoonshotAI = spec.MoonshotAI
@@ -34,6 +34,7 @@ func TestFinalRegistryContainsOnlyApprovedChannels(t *testing.T) {
GPTLoad,
NewAPI,
CLIProxyAPI,
Sub2API,
OpenAICompatible,
}
if got := descriptorIDs(registry.List()); !reflect.DeepEqual(got, want) {
+49
View File
@@ -0,0 +1,49 @@
package modules
import (
"gpt-load/internal/channel/spec"
"gpt-load/internal/execution"
"gpt-load/internal/protocol"
)
func Sub2API() spec.Module {
return spec.Module{
Definition: spec.Definition{
ID: spec.Sub2API,
Name: "Sub2API",
Mark: "S2A",
Icon: "sub2api",
SearchTerms: []string{"sub 2 api", "s2a"},
Description: "Sub2API multi-protocol gateway",
Connection: spec.Connection{
Type: spec.ConnectionAPIKey,
CredentialInput: "batch_text",
},
Params: []spec.Field{{
Key: "base_url", Label: "Base URL", InputKind: spec.InputURL,
Required: true, Normalizer: spec.NormalizeBaseURL,
}},
Credentials: []spec.Field{{
Key: "api_key", Label: "API Key", InputKind: spec.InputSecret,
Required: true, Sensitive: true, Normalizer: spec.NormalizeNonEmpty,
}},
Provider: spec.ProviderBinding{
ProviderKind: spec.ProviderMultiProtocolGateway,
EndpointPolicy: spec.EndpointRequiredBaseURL,
},
Routes: []spec.Route{
spec.NewRoute(protocol.OpenAICompletions, execution.OperationChatCompletion, execution.RouteNative),
spec.NewRoute(protocol.OpenAICompletions, execution.OperationListModels, execution.RouteNative),
spec.NewRoute(protocol.OpenAICompletions, execution.OperationProbe, execution.RouteNative),
spec.NewResponsesCreateRoute(execution.RouteNative, spec.ResponsesStoreHandlingUpstreamManaged),
spec.NewRoute(protocol.OpenAIResponses, execution.OperationResponsesCompact, execution.RouteNative),
spec.NewRoute(protocol.OpenAIImages, execution.OperationImagesGenerate, execution.RouteNative),
spec.NewRoute(protocol.OpenAIImages, execution.OperationImagesEdit, execution.RouteNative),
spec.NewRoute(protocol.Anthropic, execution.OperationChatCompletion, execution.RouteNative),
spec.NewRoute(protocol.Anthropic, execution.OperationCountTokens, execution.RouteNative),
spec.NewRoute(protocol.Gemini, execution.OperationChatCompletion, execution.RouteNative),
spec.NewRoute(protocol.Gemini, execution.OperationCountTokens, execution.RouteNative),
},
},
}
}
+5
View File
@@ -37,6 +37,7 @@ func TestRegistryHasStableBuiltInOrderAndSearch(t *testing.T) {
GPTLoad,
NewAPI,
CLIProxyAPI,
Sub2API,
OpenAICompatible,
}
if got := descriptorIDs(registry.List()); !reflect.DeepEqual(got, wantIDs) {
@@ -69,6 +70,9 @@ func TestRegistryHasStableBuiltInOrderAndSearch(t *testing.T) {
if got := descriptorIDs(registry.Search("cpa")); !reflect.DeepEqual(got, []ID{CLIProxyAPI}) {
t.Fatalf("Search(cpa) IDs = %v", got)
}
if got := descriptorIDs(registry.Search("s2a")); !reflect.DeepEqual(got, []ID{Sub2API}) {
t.Fatalf("Search(s2a) IDs = %v", got)
}
if got := descriptorIDs(registry.Search("gptload")); !reflect.DeepEqual(got, []ID{GPTLoad}) {
t.Fatalf("Search(gptload) IDs = %v", got)
}
@@ -830,6 +834,7 @@ func TestEveryResponsesCreateChannelDeclaresStoreHandling(t *testing.T) {
GPTLoad: ResponsesStoreHandlingUpstreamManaged,
NewAPI: ResponsesStoreHandlingUpstreamManaged,
CLIProxyAPI: ResponsesStoreHandlingUpstreamManaged,
Sub2API: ResponsesStoreHandlingUpstreamManaged,
OpenAICompatible: ResponsesStoreHandlingStateless,
}
+1 -1
View File
@@ -41,7 +41,7 @@ func TestBuiltInRouteGolden(t *testing.T) {
}
digest := fmt.Sprintf("%x", sha256.Sum256([]byte(strings.Join(got, "\n"))))
const wantDigest = "f3fb3165ac0e80bd6a59053e8a8806c4d5245a80a66b11b55048e50ac770cbdb"
const wantDigest = "661dd96120845d8e9a61cab4c32a82c3dacb50be2791e73a90b743987f81eabf"
if digest != wantDigest {
t.Fatalf("built-in routes changed: digest = %s, want %s\n%s", digest, wantDigest, strings.Join(got, "\n"))
}
+1
View File
@@ -26,6 +26,7 @@ const (
GPTLoad ID = "gpt_load"
NewAPI ID = "newapi"
CLIProxyAPI ID = "cliproxyapi"
Sub2API ID = "sub2api"
OpenAICompatible ID = "openai_compatible"
DeepSeek ID = "deepseek"
MoonshotAI ID = "moonshotai"
+92
View File
@@ -0,0 +1,92 @@
package channel
import (
"encoding/json"
"reflect"
"testing"
"gpt-load/internal/execution"
"gpt-load/internal/protocol"
)
func TestSub2APIChannelContract(t *testing.T) {
t.Parallel()
registry := NewRegistry()
descriptor, ok := registry.Get(Sub2API)
if !ok {
t.Fatal("Sub2API channel is not registered")
}
if descriptor.Name != "Sub2API" || descriptor.Mark != "S2A" || descriptor.Icon != "sub2api" {
t.Fatalf("Sub2API identity = %#v", descriptor)
}
if !reflect.DeepEqual(descriptor.SearchTerms, []string{"sub 2 api", "s2a"}) {
t.Fatalf("Sub2API search terms = %#v", descriptor.SearchTerms)
}
if descriptor.Connection.Type != "api_key" || descriptor.Connection.CredentialInput != "batch_text" {
t.Fatalf("Sub2API connection = %#v", descriptor.Connection)
}
if len(descriptor.ParamFields) != 1 {
t.Fatalf("Sub2API params = %#v", descriptor.ParamFields)
}
if field := descriptor.ParamFields[0]; field.Key != "base_url" || field.InputKind != InputURL || !field.Required || field.Sensitive {
t.Fatalf("Sub2API base_url field = %#v", field)
}
if len(descriptor.CredentialFields) != 1 {
t.Fatalf("Sub2API credentials = %#v", descriptor.CredentialFields)
}
if field := descriptor.CredentialFields[0]; field.Key != "api_key" || field.InputKind != InputSecret || !field.Required || !field.Sensitive {
t.Fatalf("Sub2API api_key field = %#v", field)
}
if !descriptor.Capabilities.ModelDiscovery || !descriptor.Capabilities.OutboundProxy {
t.Fatalf("Sub2API capabilities = %#v", descriptor.Capabilities)
}
target, err := registry.Resolve(Sub2API, json.RawMessage(`{"base_url":"HTTPS://SUB2API.EXAMPLE:443/team-a/"}`))
if err != nil {
t.Fatalf("Resolve(Sub2API) error = %v", err)
}
if target.ProviderKind != ProviderMultiProtocolGateway || target.CatalogProviderID != "" {
t.Fatalf("Sub2API target = %#v", target)
}
if got := string(target.TargetConfig); got != `{"base_url":"https://sub2api.example/team-a"}` {
t.Fatalf("Sub2API target config = %s", got)
}
if target.SupportsResponsesLifecycle() {
t.Fatal("Sub2API must not advertise a complete Responses lifecycle")
}
wantRoutes := map[protocol.Protocol]map[execution.Operation]RouteMode{
protocol.OpenAICompletions: {
execution.OperationChatCompletion: RouteNative,
execution.OperationListModels: RouteNative,
execution.OperationProbe: RouteNative,
},
protocol.OpenAIResponses: {
execution.OperationResponsesCreate: RouteNative,
execution.OperationResponsesCompact: RouteNative,
},
protocol.OpenAIImages: {
execution.OperationImagesGenerate: RouteNative,
execution.OperationImagesEdit: RouteNative,
},
protocol.Anthropic: {
execution.OperationChatCompletion: RouteNative,
execution.OperationCountTokens: RouteNative,
},
protocol.Gemini: {
execution.OperationChatCompletion: RouteNative,
execution.OperationCountTokens: RouteNative,
},
}
gotRoutes := make(map[protocol.Protocol]map[execution.Operation]RouteMode)
for _, route := range descriptor.Routes {
if gotRoutes[route.ClientProtocol] == nil {
gotRoutes[route.ClientProtocol] = make(map[execution.Operation]RouteMode)
}
gotRoutes[route.ClientProtocol][route.Operation] = route.RouteMode
}
if !reflect.DeepEqual(gotRoutes, wantRoutes) {
t.Fatalf("Sub2API routes = %#v, want %#v", gotRoutes, wantRoutes)
}
}
@@ -946,6 +946,7 @@ func TestProductionRuntimeManagerPreservesUpstreamManagedResponsesStore(t *testi
{channelID: channel.XAI, wantPath: "/v1/responses"},
{channelID: channel.NewAPI, wantPath: "/v1/responses"},
{channelID: channel.CLIProxyAPI, wantPath: "/v1/responses"},
{channelID: channel.Sub2API, wantPath: "/v1/responses"},
}
for _, test := range tests {
for _, request := range []struct {
+30
View File
@@ -0,0 +1,30 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<defs>
<linearGradient id="s2a-bg" x1="72" y1="44" x2="442" y2="478" gradientUnits="userSpaceOnUse">
<stop stop-color="#142B56"/>
<stop offset=".52" stop-color="#0A1A39"/>
<stop offset="1" stop-color="#061127"/>
</linearGradient>
<radialGradient id="s2a-ambient" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(168 92) rotate(51) scale(342 382)">
<stop stop-color="#3E68B0" stop-opacity=".28"/>
<stop offset="1" stop-color="#3E68B0" stop-opacity="0"/>
</radialGradient>
<linearGradient id="s2a-brand" x1="4" y1="4" x2="20" y2="20" gradientUnits="userSpaceOnUse">
<stop stop-color="#79F4BD"/>
<stop offset=".48" stop-color="#39D9E7"/>
<stop offset="1" stop-color="#3875F6"/>
</linearGradient>
<filter id="s2a-shadow" x="-25%" y="-25%" width="150%" height="165%" color-interpolation-filters="sRGB">
<feDropShadow dx="0" dy=".55" stdDeviation=".65" flood-color="#020817" flood-opacity=".34"/>
</filter>
</defs>
<rect x="16" y="16" width="480" height="480" rx="120" fill="url(#s2a-bg)"/>
<rect x="16" y="16" width="480" height="480" rx="120" fill="url(#s2a-ambient)"/>
<rect x="16.75" y="16.75" width="478.5" height="478.5" rx="119.25" fill="none" stroke="#A5BFFF" stroke-opacity=".16" stroke-width="1.5"/>
<g transform="translate(52 52) scale(17)" fill="none" stroke="url(#s2a-brand)" stroke-linecap="round" stroke-linejoin="round" stroke-width="2.7" filter="url(#s2a-shadow)">
<path d="m19.25 7.65-2.55-3.2a1.33 1.33 0 0 0-1.03-.5H8.58c-.34 0-.67.13-.91.37L4.15 7.65c-.93.88-.6 1.52.65 2.3l9.55 5.97"/>
<path d="m4.75 16.35 2.55 3.2c.25.31.63.5 1.03.5h7.09c.34 0 .67-.13.91-.37l3.52-3.33c.93-.88.6-1.52-.65-2.3L9.65 8.08"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

@@ -88,6 +88,9 @@ function parameterHelp(field: ChannelFieldDto): string {
if (field.key === 'base_url' && props.channelId === 'cliproxyapi') {
return t('group.settings.base.cpaUrlDescription')
}
if (field.key === 'base_url' && props.channelId === 'sub2api') {
return t('group.settings.base.sub2ApiUrlDescription')
}
return t('group.settings.base.urlWarning')
}
@@ -77,6 +77,9 @@ function baseURLDescription(): string {
if (props.channel?.channel_id === 'cliproxyapi') {
return t('import.connection.cpaUrlDescription')
}
if (props.channel?.channel_id === 'sub2api') {
return t('import.connection.sub2ApiUrlDescription')
}
if (props.channel?.channel_id === 'openai_compatible') {
return t('import.connection.compatibleUrlDescription')
}
+2
View File
@@ -231,6 +231,8 @@ export default {
'Enter the New API gateway root or deployment prefix. Do not include standard protocol paths such as /v1 or /v1beta.',
cpaUrlDescription:
'Enter the CLIProxyAPI gateway root or deployment prefix. Do not include standard protocol paths such as /v1 or /v1beta.',
sub2ApiUrlDescription:
'Enter the Sub2API gateway root or deployment prefix. Do not include standard protocol paths such as /v1 or /v1beta.',
customUrl: 'Custom upstream URL',
customUrlHelp: 'Uses the channel preset or SDK official address by default.',
paramRequired: 'Enter {field}.',
+2
View File
@@ -104,6 +104,8 @@ export default {
'Enter the New API gateway root or deployment prefix, for example https://new-api.example.com. Do not include standard protocol paths such as /v1 or /v1beta.',
cpaUrlDescription:
'Enter the CLIProxyAPI gateway root or deployment prefix, for example https://cpa.example.com. Do not include standard protocol paths such as /v1 or /v1beta.',
sub2ApiUrlDescription:
'Enter the Sub2API gateway root or deployment prefix, for example https://sub2api.example.com. Do not include standard protocol paths such as /v1 or /v1beta.',
urlDescriptionWithDefault: 'Default URL: {url}',
urlVersionWarning: 'Version path may differ; please verify',
customUrl: 'Custom upstream URL',
+2
View File
@@ -230,6 +230,8 @@ export default {
'New API ゲートウェイのルートまたはデプロイ接頭辞を入力してください。/v1 や /v1beta などの標準プロトコルパスは含めないでください。',
cpaUrlDescription:
'CLIProxyAPI ゲートウェイのルートまたはデプロイ接頭辞を入力してください。/v1 や /v1beta などの標準プロトコルパスは含めないでください。',
sub2ApiUrlDescription:
'Sub2API ゲートウェイのルートまたはデプロイ接頭辞を入力してください。/v1 や /v1beta などの標準プロトコルパスは含めないでください。',
customUrl: 'カスタム上流 URL',
customUrlHelp: '既定ではチャネル設定または SDK 公式アドレスを使用します。',
paramRequired: '{field} を入力してください。',
+2
View File
@@ -102,6 +102,8 @@ export default {
'New API ゲートウェイのルートまたはデプロイ接頭辞を入力します(例: https://new-api.example.com)。/v1 や /v1beta などの標準プロトコルパスは含めないでください。',
cpaUrlDescription:
'CLIProxyAPI ゲートウェイのルートまたはデプロイ接頭辞を入力します(例: https://cpa.example.com)。/v1 や /v1beta などの標準プロトコルパスは含めないでください。',
sub2ApiUrlDescription:
'Sub2API ゲートウェイのルートまたはデプロイ接頭辞を入力します(例: https://sub2api.example.com)。/v1 や /v1beta などの標準プロトコルパスは含めないでください。',
urlDescriptionWithDefault: '既定の URL:{url}',
urlVersionWarning: 'バージョンパスが異なる可能性があります。確認してください',
customUrl: 'カスタム上流 URL',
+2
View File
@@ -225,6 +225,8 @@ export default {
'填写 New API 网关根地址或部署前缀;不要填写 /v1 或 /v1beta 等标准协议路径。',
cpaUrlDescription:
'填写 CLIProxyAPI 网关根地址或部署前缀;不要填写 /v1 或 /v1beta 等标准协议路径。',
sub2ApiUrlDescription:
'填写 Sub2API 网关根地址或部署前缀;不要填写 /v1 或 /v1beta 等标准协议路径。',
customUrl: '自定义上游地址',
customUrlHelp: '默认使用渠道预设或 SDK 官方地址。',
paramRequired: '请输入{field}。',
+2
View File
@@ -98,6 +98,8 @@ export default {
'填写 New API 网关根地址或部署前缀,例如 https://new-api.example.com;不要填写 /v1 或 /v1beta 等标准协议路径',
cpaUrlDescription:
'填写 CLIProxyAPI 网关根地址或部署前缀,例如 https://cpa.example.com;不要填写 /v1 或 /v1beta 等标准协议路径',
sub2ApiUrlDescription:
'填写 Sub2API 网关根地址或部署前缀,例如 https://sub2api.example.com;不要填写 /v1 或 /v1beta 等标准协议路径',
urlDescriptionWithDefault: '默认地址:{url}',
urlVersionWarning: '版本路径可能不一致,请确认',
customUrl: '自定义上游地址',