diff --git a/internal/channel/builtins.go b/internal/channel/builtins.go index 25f0f9ba..50741c32 100644 --- a/internal/channel/builtins.go +++ b/internal/channel/builtins.go @@ -31,6 +31,7 @@ func builtInModules() []spec.Module { modules.GPTLoad(), modules.NewAPI(), modules.CLIProxyAPI(), + modules.Sub2API(), modules.OpenAICompatible(), } } diff --git a/internal/channel/channel.go b/internal/channel/channel.go index 804f3c0a..af2a6b84 100644 --- a/internal/channel/channel.go +++ b/internal/channel/channel.go @@ -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 diff --git a/internal/channel/final_provider_contract_test.go b/internal/channel/final_provider_contract_test.go index aea06bb4..545977d8 100644 --- a/internal/channel/final_provider_contract_test.go +++ b/internal/channel/final_provider_contract_test.go @@ -34,6 +34,7 @@ func TestFinalRegistryContainsOnlyApprovedChannels(t *testing.T) { GPTLoad, NewAPI, CLIProxyAPI, + Sub2API, OpenAICompatible, } if got := descriptorIDs(registry.List()); !reflect.DeepEqual(got, want) { diff --git a/internal/channel/modules/sub2api.go b/internal/channel/modules/sub2api.go new file mode 100644 index 00000000..ca55921b --- /dev/null +++ b/internal/channel/modules/sub2api.go @@ -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), + }, + }, + } +} diff --git a/internal/channel/registry_test.go b/internal/channel/registry_test.go index 28a955e0..5468135c 100644 --- a/internal/channel/registry_test.go +++ b/internal/channel/registry_test.go @@ -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, } diff --git a/internal/channel/route_golden_test.go b/internal/channel/route_golden_test.go index 08ad0c5e..10ca4fd7 100644 --- a/internal/channel/route_golden_test.go +++ b/internal/channel/route_golden_test.go @@ -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")) } diff --git a/internal/channel/spec/definition.go b/internal/channel/spec/definition.go index c173d397..8a466c85 100644 --- a/internal/channel/spec/definition.go +++ b/internal/channel/spec/definition.go @@ -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" diff --git a/internal/channel/sub2api_module_test.go b/internal/channel/sub2api_module_test.go new file mode 100644 index 00000000..58250328 --- /dev/null +++ b/internal/channel/sub2api_module_test.go @@ -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) + } +} diff --git a/internal/execution/bifrost/runtime_manager_test.go b/internal/execution/bifrost/runtime_manager_test.go index a521584c..0a881f15 100644 --- a/internal/execution/bifrost/runtime_manager_test.go +++ b/internal/execution/bifrost/runtime_manager_test.go @@ -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 { diff --git a/web/src/assets/channels/sub2api.svg b/web/src/assets/channels/sub2api.svg new file mode 100644 index 00000000..c9333b45 --- /dev/null +++ b/web/src/assets/channels/sub2api.svg @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/web/src/features/groups/settings/GroupSettingsBaseForm.vue b/web/src/features/groups/settings/GroupSettingsBaseForm.vue index c1c28c08..04c4d175 100644 --- a/web/src/features/groups/settings/GroupSettingsBaseForm.vue +++ b/web/src/features/groups/settings/GroupSettingsBaseForm.vue @@ -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') } diff --git a/web/src/features/import/ImportConnectionSection.vue b/web/src/features/import/ImportConnectionSection.vue index 65ae7761..0bed7128 100644 --- a/web/src/features/import/ImportConnectionSection.vue +++ b/web/src/features/import/ImportConnectionSection.vue @@ -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') } diff --git a/web/src/i18n/locales/en-US/group.ts b/web/src/i18n/locales/en-US/group.ts index b8a583ba..dd487cc6 100644 --- a/web/src/i18n/locales/en-US/group.ts +++ b/web/src/i18n/locales/en-US/group.ts @@ -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}.', diff --git a/web/src/i18n/locales/en-US/import.ts b/web/src/i18n/locales/en-US/import.ts index f074e619..0858f38b 100644 --- a/web/src/i18n/locales/en-US/import.ts +++ b/web/src/i18n/locales/en-US/import.ts @@ -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', diff --git a/web/src/i18n/locales/ja-JP/group.ts b/web/src/i18n/locales/ja-JP/group.ts index 7418b4df..72e8969f 100644 --- a/web/src/i18n/locales/ja-JP/group.ts +++ b/web/src/i18n/locales/ja-JP/group.ts @@ -230,6 +230,8 @@ export default { 'New API ゲートウェイのルートまたはデプロイ接頭辞を入力してください。/v1 や /v1beta などの標準プロトコルパスは含めないでください。', cpaUrlDescription: 'CLIProxyAPI ゲートウェイのルートまたはデプロイ接頭辞を入力してください。/v1 や /v1beta などの標準プロトコルパスは含めないでください。', + sub2ApiUrlDescription: + 'Sub2API ゲートウェイのルートまたはデプロイ接頭辞を入力してください。/v1 や /v1beta などの標準プロトコルパスは含めないでください。', customUrl: 'カスタム上流 URL', customUrlHelp: '既定ではチャネル設定または SDK 公式アドレスを使用します。', paramRequired: '{field} を入力してください。', diff --git a/web/src/i18n/locales/ja-JP/import.ts b/web/src/i18n/locales/ja-JP/import.ts index daaf1782..a33cbe51 100644 --- a/web/src/i18n/locales/ja-JP/import.ts +++ b/web/src/i18n/locales/ja-JP/import.ts @@ -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', diff --git a/web/src/i18n/locales/zh-CN/group.ts b/web/src/i18n/locales/zh-CN/group.ts index 613cb6c9..013acb11 100644 --- a/web/src/i18n/locales/zh-CN/group.ts +++ b/web/src/i18n/locales/zh-CN/group.ts @@ -225,6 +225,8 @@ export default { '填写 New API 网关根地址或部署前缀;不要填写 /v1 或 /v1beta 等标准协议路径。', cpaUrlDescription: '填写 CLIProxyAPI 网关根地址或部署前缀;不要填写 /v1 或 /v1beta 等标准协议路径。', + sub2ApiUrlDescription: + '填写 Sub2API 网关根地址或部署前缀;不要填写 /v1 或 /v1beta 等标准协议路径。', customUrl: '自定义上游地址', customUrlHelp: '默认使用渠道预设或 SDK 官方地址。', paramRequired: '请输入{field}。', diff --git a/web/src/i18n/locales/zh-CN/import.ts b/web/src/i18n/locales/zh-CN/import.ts index 3b12a909..84c84f98 100644 --- a/web/src/i18n/locales/zh-CN/import.ts +++ b/web/src/i18n/locales/zh-CN/import.ts @@ -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: '自定义上游地址',