feat(channel): 增加 Sub2API 渠道 (#567)
This commit is contained in:
1 parent
c4cefc0655
commit
ae59a0ae85
18 files changed
+200
-1
No files matched your search
@@ -31,6 +31,7 @@ func builtInModules() []spec.Module {
|
||||
modules.GPTLoad(),
|
||||
modules.NewAPI(),
|
||||
modules.CLIProxyAPI(),
|
||||
modules.Sub2API(),
|
||||
modules.OpenAICompatible(),
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
@@ -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}.',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -230,6 +230,8 @@ export default {
|
||||
'New API ゲートウェイのルートまたはデプロイ接頭辞を入力してください。/v1 や /v1beta などの標準プロトコルパスは含めないでください。',
|
||||
cpaUrlDescription:
|
||||
'CLIProxyAPI ゲートウェイのルートまたはデプロイ接頭辞を入力してください。/v1 や /v1beta などの標準プロトコルパスは含めないでください。',
|
||||
sub2ApiUrlDescription:
|
||||
'Sub2API ゲートウェイのルートまたはデプロイ接頭辞を入力してください。/v1 や /v1beta などの標準プロトコルパスは含めないでください。',
|
||||
customUrl: 'カスタム上流 URL',
|
||||
customUrlHelp: '既定ではチャネル設定または SDK 公式アドレスを使用します。',
|
||||
paramRequired: '{field} を入力してください。',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -225,6 +225,8 @@ export default {
|
||||
'填写 New API 网关根地址或部署前缀;不要填写 /v1 或 /v1beta 等标准协议路径。',
|
||||
cpaUrlDescription:
|
||||
'填写 CLIProxyAPI 网关根地址或部署前缀;不要填写 /v1 或 /v1beta 等标准协议路径。',
|
||||
sub2ApiUrlDescription:
|
||||
'填写 Sub2API 网关根地址或部署前缀;不要填写 /v1 或 /v1beta 等标准协议路径。',
|
||||
customUrl: '自定义上游地址',
|
||||
customUrlHelp: '默认使用渠道预设或 SDK 官方地址。',
|
||||
paramRequired: '请输入{field}。',
|
||||
|
||||
@@ -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: '自定义上游地址',
|
||||
|
||||
Reference in new issue
Block a user