feat(admin): 渠道管理 - 支持渠道级出站代理
This commit is contained in:
@@ -93,6 +93,7 @@ type ChannelRequest struct {
|
||||
Models []string `json:"models"`
|
||||
Headers []OutboundHeader `json:"headers"`
|
||||
Enabled *bool `json:"enabled"`
|
||||
ProxyURL string `json:"proxyUrl"`
|
||||
}
|
||||
|
||||
type PublicModelChannel struct {
|
||||
@@ -106,6 +107,7 @@ type PublicModelChannel struct {
|
||||
APIKey string `json:"apiKey"`
|
||||
APIFormat string `json:"apiFormat"`
|
||||
ConcurrencyLimit int `json:"concurrencyLimit"`
|
||||
ProxyURL string `json:"proxyUrl"`
|
||||
Models []string `json:"models"`
|
||||
ModelCosts []PublicChannelModelPrice `json:"modelCosts"`
|
||||
Headers []OutboundHeader `json:"headers,omitempty"`
|
||||
@@ -187,6 +189,7 @@ func (s *Service) channelFromRequest(req ChannelRequest, channel model.ModelChan
|
||||
}
|
||||
channel.ModelsJSON = string(modelsJSON)
|
||||
channel.HeadersJSON = headersJSON
|
||||
channel.ProxyURL = strings.TrimSpace(req.ProxyURL)
|
||||
if req.Enabled != nil {
|
||||
channel.Enabled = *req.Enabled
|
||||
}
|
||||
@@ -206,6 +209,9 @@ func mergeChannelRequest(req ChannelRequest, channel model.ModelChannel) Channel
|
||||
if req.Headers == nil {
|
||||
req.Headers, _ = ParseOutboundHeadersJSON(channel.HeadersJSON)
|
||||
}
|
||||
if strings.TrimSpace(req.ProxyURL) == "" {
|
||||
req.ProxyURL = channel.ProxyURL
|
||||
}
|
||||
return req
|
||||
}
|
||||
|
||||
@@ -232,6 +238,7 @@ func publicChannel(channel model.ModelChannel, admin bool, channelModels []model
|
||||
}
|
||||
apiKey := ""
|
||||
baseURL := channel.BaseURL
|
||||
proxyURL := ""
|
||||
var headers []OutboundHeader
|
||||
if channel.Scope == model.ChannelScopeSystem {
|
||||
if !admin {
|
||||
@@ -244,6 +251,9 @@ func publicChannel(channel model.ModelChannel, admin bool, channelModels []model
|
||||
} else if admin {
|
||||
apiKey = channel.APIKey
|
||||
}
|
||||
if admin {
|
||||
proxyURL = channel.ProxyURL
|
||||
}
|
||||
return PublicModelChannel{
|
||||
ID: channel.ID,
|
||||
UserID: channel.UserID,
|
||||
@@ -255,6 +265,7 @@ func publicChannel(channel model.ModelChannel, admin bool, channelModels []model
|
||||
APIKey: apiKey,
|
||||
APIFormat: channel.APIFormat,
|
||||
ConcurrencyLimit: channel.ConcurrencyLimit,
|
||||
ProxyURL: proxyURL,
|
||||
Models: models,
|
||||
ModelCosts: modelCosts,
|
||||
Headers: headers,
|
||||
|
||||
@@ -189,16 +189,22 @@ func TestChannelFromRequestStoresAndClearsHeaders(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublicChannelOnlyReturnsSystemHeadersToAdmin(t *testing.T) {
|
||||
channel := model.ModelChannel{ID: "system-1", Scope: model.ChannelScopeSystem, BaseURL: "https://example.com/v1", HeadersJSON: `[{"name":"X-Gateway-Tenant","value":"tenant-a"}]`}
|
||||
func TestPublicChannelOnlyReturnsSystemHeadersAndProxyToAdmin(t *testing.T) {
|
||||
channel := model.ModelChannel{ID: "system-1", Scope: model.ChannelScopeSystem, BaseURL: "https://example.com/v1", HeadersJSON: `[{"name":"X-Gateway-Tenant","value":"tenant-a"}]`, ProxyURL: "socks5://user:secret@127.0.0.1:1080"}
|
||||
adminView := publicChannel(channel, true, nil)
|
||||
if len(adminView.Headers) != 1 || adminView.Headers[0].Name != "X-Gateway-Tenant" {
|
||||
t.Fatalf("admin headers = %#v", adminView.Headers)
|
||||
}
|
||||
if adminView.ProxyURL != channel.ProxyURL {
|
||||
t.Fatalf("admin proxy URL = %q", adminView.ProxyURL)
|
||||
}
|
||||
userView := publicChannel(channel, false, nil)
|
||||
if len(userView.Headers) != 0 {
|
||||
t.Fatalf("user headers = %#v", userView.Headers)
|
||||
}
|
||||
if userView.ProxyURL != "" {
|
||||
t.Fatalf("user proxy URL = %q", userView.ProxyURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChannelFromRequestRejectsInvalidConcurrencyLimit(t *testing.T) {
|
||||
|
||||
@@ -112,7 +112,7 @@ func (s *Service) TestAdminChannelModel(ctx context.Context, actor *model.User,
|
||||
defer cancel()
|
||||
testCtx = context.WithValue(testCtx, providerAnalyticsKey{}, providerAnalyticsContext{
|
||||
Service: s, Billing: s.taskBilling(), UserID: actor.ID, ChannelID: channel.ID, Capability: capability,
|
||||
Operation: "admin_model_test", Model: modelKey, VideoSeconds: videoSecondsValue,
|
||||
Operation: "admin_model_test", Model: modelKey, VideoSeconds: videoSecondsValue, ProxyURL: channel.ProxyURL,
|
||||
})
|
||||
testCtx = withProtocolRegistry(testCtx, s.protocolRegistry())
|
||||
startedAt := time.Now()
|
||||
|
||||
@@ -12,6 +12,8 @@ func (s *Service) ValidateChannelOutboundURL(rawURL string) (*url.URL, error) {
|
||||
return ValidateOutboundURL(rawURL)
|
||||
}
|
||||
|
||||
func (s *Service) OutboundHTTPClientForChannel(timeout time.Duration, _ *url.URL) *http.Client {
|
||||
return OutboundHTTPClient(timeout)
|
||||
// OutboundHTTPClientForChannel 返回渠道专用的出站 HTTP 客户端。
|
||||
// proxyURL 非空时使用渠道级代理(socks5/http/https),否则回退到全局客户端。
|
||||
func (s *Service) OutboundHTTPClientForChannel(timeout time.Duration, _ *url.URL, proxyURL string) *http.Client {
|
||||
return OutboundHTTPClientWithProxy(timeout, proxyURL)
|
||||
}
|
||||
|
||||
@@ -30,6 +30,10 @@ func OutboundHTTPClient(timeout time.Duration) *http.Client {
|
||||
return outbound.OutboundHTTPClient(timeout)
|
||||
}
|
||||
|
||||
func OutboundHTTPClientWithProxy(timeout time.Duration, proxyURL string) *http.Client {
|
||||
return outbound.OutboundHTTPClientWithProxy(timeout, proxyURL)
|
||||
}
|
||||
|
||||
func ApplyDefaultOutboundHeaders(req *http.Request) {
|
||||
outbound.ApplyDefaultOutboundHeaders(req)
|
||||
}
|
||||
|
||||
@@ -78,6 +78,7 @@ type providerAnalyticsContext struct {
|
||||
Capability string
|
||||
Operation string
|
||||
ChannelID string
|
||||
ProxyURL string
|
||||
Model string
|
||||
VideoSeconds int
|
||||
RequestKind string
|
||||
@@ -107,6 +108,11 @@ func withProviderAnalytics(ctx context.Context, service *Service, task model.Tas
|
||||
if normalized := normalizeCapability(input.Mode); normalized != "" {
|
||||
metadata.Capability = normalized
|
||||
}
|
||||
if metadata.ChannelID != "" && service != nil {
|
||||
if ch, err := service.repo.SystemChannel(metadata.ChannelID); err == nil {
|
||||
metadata.ProxyURL = ch.ProxyURL
|
||||
}
|
||||
}
|
||||
}
|
||||
return context.WithValue(ctx, providerAnalyticsKey{}, metadata)
|
||||
}
|
||||
|
||||
@@ -257,10 +257,12 @@ func doBinaryWithConsumer(req *http.Request, onChunk func(string, []byte)) ([]by
|
||||
cacheManagementRequest := requestKind == "cache-create" || requestKind == "cache-delete"
|
||||
responseLimit := maxProviderResponseBytes
|
||||
channelID := ""
|
||||
channelProxyURL := ""
|
||||
if metadata, ok := req.Context().Value(providerAnalyticsKey{}).(providerAnalyticsContext); ok && metadata.Service != nil {
|
||||
runtimeService = metadata.Service
|
||||
coordinator = metadata.Service.coordinator
|
||||
channelID = metadata.ChannelID
|
||||
channelProxyURL = metadata.ProxyURL
|
||||
policy, err := metadata.Service.RuntimePolicy()
|
||||
if err != nil {
|
||||
err = fmt.Errorf("读取生成资源限制失败:%w", err)
|
||||
@@ -302,7 +304,7 @@ func doBinaryWithConsumer(req *http.Request, onChunk func(string, []byte)) ([]by
|
||||
return nil, "", err
|
||||
}
|
||||
ApplyDefaultOutboundHeaders(req)
|
||||
client := OutboundHTTPClient(requestTimeout)
|
||||
client := OutboundHTTPClientWithProxy(requestTimeout, channelProxyURL)
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
err = providerConnectionError(err)
|
||||
|
||||
@@ -130,7 +130,18 @@ func (s *Service) downloadTaskMedia(ctx context.Context, config providerConfig,
|
||||
ApplyOutboundHeaders(req, config.Headers)
|
||||
}
|
||||
ApplyDefaultOutboundHeaders(req)
|
||||
response, err := OutboundHTTPClient(3 * time.Minute).Do(req)
|
||||
// 结果文件可能托管在渠道同域(sameProviderOrigin 时还带渠道鉴权),
|
||||
// 渠道配置了出站代理时下载必须走同一代理,否则配代理渠道的结果无法取回。
|
||||
proxyURL := ""
|
||||
if metadata, ok := ctx.Value(providerAnalyticsKey{}).(providerAnalyticsContext); ok {
|
||||
proxyURL = metadata.ProxyURL
|
||||
}
|
||||
if strings.TrimSpace(proxyURL) == "" && strings.TrimSpace(config.ChannelID) != "" {
|
||||
if ch, err := s.repo.SystemChannel(config.ChannelID); err == nil {
|
||||
proxyURL = ch.ProxyURL
|
||||
}
|
||||
}
|
||||
response, err := OutboundHTTPClientWithProxy(3*time.Minute, proxyURL).Do(req)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const CurrentSchemaVersion int64 = 47
|
||||
const CurrentSchemaVersion int64 = 48
|
||||
|
||||
const baselineSchemaChecksum = "sha256:open-ai-canvas-schema-v1-20260830"
|
||||
const schemaMigrationAppliedAtIndexChecksum = "sha256:schema-migrations-applied-at-index-v2-20260830"
|
||||
@@ -151,6 +151,12 @@ var schemaMigrations = []migration{
|
||||
}},
|
||||
{version: 46, name: "skill_curation", checksum: "sha256:skill-curation-v46-20261005", apply: migrateSkillCuration},
|
||||
{version: 47, name: "skill_curation_roots", checksum: "sha256:skill-curation-roots-v47-20261005", apply: migrateSkillCurationRoots},
|
||||
{version: 48, name: "channel_proxy_url", checksum: "sha256:channel-proxy-url-v48-20261009", apply: func(tx *gorm.DB) error {
|
||||
if !tx.Migrator().HasColumn(&model.ModelChannel{}, "ProxyURL") {
|
||||
return tx.Migrator().AddColumn(&model.ModelChannel{}, "ProxyURL")
|
||||
}
|
||||
return nil
|
||||
}},
|
||||
}
|
||||
|
||||
func migratePrefixedIDSequenceReconcile(tx *gorm.DB) error {
|
||||
|
||||
@@ -434,7 +434,7 @@ func proxySystemRequestPath(c *gin.Context, svc *service.Service, user *model.Us
|
||||
status := model.ApiCallStatusSucceeded
|
||||
statusCode := 0
|
||||
errorText := ""
|
||||
resp, err := svc.OutboundHTTPClientForChannel(35*time.Minute, validatedTarget).Do(upstreamReq)
|
||||
resp, err := svc.OutboundHTTPClientForChannel(35*time.Minute, validatedTarget, channel.ProxyURL).Do(upstreamReq)
|
||||
if err != nil {
|
||||
status = model.ApiCallStatusFailed
|
||||
errorText = err.Error()
|
||||
|
||||
@@ -20,7 +20,10 @@ type ModelChannel struct {
|
||||
SecretKey string `json:"-"`
|
||||
APIFormat string `json:"apiFormat" gorm:"size:24"`
|
||||
ConcurrencyLimit int `json:"concurrencyLimit"`
|
||||
ModelsJSON string `json:"modelsJson" gorm:"type:text"`
|
||||
// ProxyURL 是渠道级出站代理地址,支持 socks5://、http://、https://。
|
||||
// 留空时使用全局环境变量代理。
|
||||
ProxyURL string `json:"proxyUrl" gorm:"size:512"`
|
||||
ModelsJSON string `json:"modelsJson" gorm:"type:text"`
|
||||
// RetiredModelsJSON 记录已被一个模型家族吸收的上游 SKU,防止目录拉取时重新创建重复记录。
|
||||
RetiredModelsJSON string `json:"-" gorm:"type:text"`
|
||||
HeadersJSON string `json:"-" gorm:"type:text"`
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
"yingce/backend/internal/kernel"
|
||||
|
||||
"golang.org/x/net/http/httpproxy"
|
||||
"golang.org/x/net/proxy"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -109,6 +110,30 @@ func OutboundHTTPClient(timeout time.Duration) *http.Client {
|
||||
}
|
||||
}
|
||||
|
||||
// OutboundHTTPClientWithProxy 创建使用指定代理的出站客户端。
|
||||
// proxyURL 支持 socks5://、http://、https:// 格式;空字符串时回退到全局客户端。
|
||||
func OutboundHTTPClientWithProxy(timeout time.Duration, proxyURL string) *http.Client {
|
||||
proxyURL = strings.TrimSpace(proxyURL)
|
||||
if proxyURL == "" {
|
||||
return OutboundHTTPClient(timeout)
|
||||
}
|
||||
transport := newOutboundTransportWithProxy(proxyURL)
|
||||
if transport == nil {
|
||||
return OutboundHTTPClient(timeout)
|
||||
}
|
||||
return &http.Client{
|
||||
Transport: transport,
|
||||
Timeout: timeout,
|
||||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||
if len(via) >= maxOutboundRedirects {
|
||||
return errors.New("外部服务重定向次数过多")
|
||||
}
|
||||
_, err := ValidateOutboundURL(req.URL.String())
|
||||
return err
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func CustomRelayHTTPClient(timeout time.Duration) *http.Client {
|
||||
return &http.Client{
|
||||
Transport: customRelayTransport,
|
||||
@@ -284,6 +309,50 @@ func outboundProxyFromEnvironment(req *http.Request) (*url.URL, error) {
|
||||
return httpproxy.FromEnvironment().ProxyFunc()(req.URL)
|
||||
}
|
||||
|
||||
// newOutboundTransportWithProxy 创建使用指定代理的 Transport。
|
||||
// 支持 socks5://、socks5h://、http://、https://;返回 nil 表示代理 URL 无效。
|
||||
// 目标主机名不做本地解析,直接交给代理服务器远端解析(socks5 与 socks5h 同义)。
|
||||
// 本地 DNS 在 TUN/VPN 模式下会返回 fake-ip,若先本地解析再经代
|
||||
// 理连接会导致远端拿到不可达的假 IP;交给远端解析可避免此问题。
|
||||
func newOutboundTransportWithProxy(proxyURL string) *http.Transport {
|
||||
parsed, err := url.Parse(proxyURL)
|
||||
if err != nil || parsed.Host == "" {
|
||||
return nil
|
||||
}
|
||||
scheme := strings.ToLower(parsed.Scheme)
|
||||
forwardDialer := &net.Dialer{Timeout: 15 * time.Second, KeepAlive: 30 * time.Second}
|
||||
|
||||
transport := &http.Transport{
|
||||
ForceAttemptHTTP2: true,
|
||||
MaxIdleConns: 100,
|
||||
MaxIdleConnsPerHost: 20,
|
||||
IdleConnTimeout: 90 * time.Second,
|
||||
TLSHandshakeTimeout: 15 * time.Second,
|
||||
ExpectContinueTimeout: time.Second,
|
||||
}
|
||||
|
||||
switch scheme {
|
||||
case "socks5", "socks5h":
|
||||
transport.Proxy = nil
|
||||
d, derr := proxy.FromURL(parsed, forwardDialer)
|
||||
if derr != nil {
|
||||
return nil
|
||||
}
|
||||
contextDialer, ok := d.(proxy.ContextDialer)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
transport.DialContext = contextDialer.DialContext
|
||||
case "http", "https":
|
||||
transport.Proxy = http.ProxyURL(parsed)
|
||||
transport.DialContext = forwardDialer.DialContext
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
|
||||
return transport
|
||||
}
|
||||
|
||||
func configuredProxyHost(host string) bool {
|
||||
host = normalizeOutboundHost(host)
|
||||
for _, name := range []string{"HTTPS_PROXY", "https_proxy", "HTTP_PROXY", "http_proxy"} {
|
||||
|
||||
@@ -47,6 +47,7 @@ var (
|
||||
NormalizeOutboundHeaders = app.NormalizeOutboundHeaders
|
||||
NotFound = app.NotFound
|
||||
OutboundHTTPClient = app.OutboundHTTPClient
|
||||
OutboundHTTPClientWithProxy = app.OutboundHTTPClientWithProxy
|
||||
ParseOutboundHeadersJSON = app.ParseOutboundHeadersJSON
|
||||
PaymentNotificationResponseFor = app.PaymentNotificationResponseFor
|
||||
PaymentNotificationResponseForWithRegistry = app.PaymentNotificationResponseForWithRegistry
|
||||
|
||||
@@ -31,7 +31,7 @@ Schema 45 新增 `upload_reservations`:`id`、`user_id`、`size`、`day`、`ex
|
||||
|
||||
## 结构迁移与版本
|
||||
|
||||
当前结构版本为 42。版本 42 `cloud_agent_pi_sessions` 新增 `cloud_agent_pi_sessions` 表,按运行 ID 保存 Pi 原生 JSONL 会话快照、用户 ID、乐观并发 revision 和更新时间;会话正文由 Agent runtime 管理,Go 只将其作为不透明快照读写,不替代有界控制检查点、事件日志或任务记录。版本 41 `resource_thumbnail` 为 `resources` 增加缩略图元数据。版本 40 `builtin_skill_tombstones` 新增内置技能删除记录,管理员删除平台技能后,后续启动同步不会重新创建该技能;版本 39 `skill_library_categories` 新增 `skill_library_categories` 表,并为 `user_skill_states` 增加 `library_category_id`,保存个人技能库分类及每个用户对技能的归类;分类不改变 `skills.tag` 的技能广场共享分类。版本 38 `prefixed_id_sequence_reconcile` 创建(若缺失)并校准 `id_sequences`:`CHANNEL`、`MODEL`、`PTIER`、`ATTEMPT`、`LMODEL`、`REVISION`、`ROUTE` 按对应业务表的最大有效 ID 前移,包含软删除记录,不回退已有序列、不重写业务 ID。分配新 ID 时再次校准并检查冲突;SQLite → PostgreSQL 迁移完成后也校准序列。
|
||||
当前结构版本为 48。版本 48 `channel_proxy_url` 为 `model_channels` 增加渠道级出站代理地址 `proxy_url`,支持 `socks5://`、`http://`、`https://`;留空时沿用全局代理。代理地址只返回管理员视图,普通用户目录不暴露渠道代理或其中的认证信息。版本 42 `cloud_agent_pi_sessions` 新增 `cloud_agent_pi_sessions` 表,按运行 ID 保存 Pi 原生 JSONL 会话快照、用户 ID、乐观并发 revision 和更新时间;会话正文由 Agent runtime 管理,Go 只将其作为不透明快照读写,不替代有界控制检查点、事件日志或任务记录。版本 41 `resource_thumbnail` 为 `resources` 增加缩略图元数据。版本 40 `builtin_skill_tombstones` 新增内置技能删除记录,管理员删除平台技能后,后续启动同步不会重新创建该技能;版本 39 `skill_library_categories` 新增 `skill_library_categories` 表,并为 `user_skill_states` 增加 `library_category_id`,保存个人技能库分类及每个用户对技能的归类;分类不改变 `skills.tag` 的技能广场共享分类。版本 38 `prefixed_id_sequence_reconcile` 创建(若缺失)并校准 `id_sequences`:`CHANNEL`、`MODEL`、`PTIER`、`ATTEMPT`、`LMODEL`、`REVISION`、`ROUTE` 按对应业务表的最大有效 ID 前移,包含软删除记录,不回退已有序列、不重写业务 ID。分配新 ID 时再次校准并检查冲突;SQLite → PostgreSQL 迁移完成后也校准序列。
|
||||
|
||||
版本 36 `cloud_agent_gemini_cache` 创建 `cloud_agent_gemini_caches`,保存用户范围内的官方 Gemini 缓存元数据;版本 37 `cloud_agent_gemini_cache_identity` 将缓存标识约束修正为 `(user_id, cache_key)`,移除旧的全局 cache_key 唯一索引。版本 35 `auth_notifications` 为认证通知与组合验证补齐 `auth_verifications`、`notification_quotas`、`sms_channels`、`sms_records` 四张表,并为 `users` 增加 `phone`、`email_verified_at`、`phone_verified_at`,为 `email_verification_codes` 增加 `attempts`;同时创建非空手机号唯一索引。版本 34 `task_media_recovery` 为 `tasks` 增加 `media_recovery_json`(text)和 `media_stage`(最长 24 字)。版本 33 `oauth_state_accepted_terms` 为 `o_auth_states` 增加非空布尔列 `accepted_terms`,默认 `false`,不替历史流程补造同意记录。
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ type ChannelFormValues = {
|
||||
useGlobalConcurrency?: boolean;
|
||||
concurrencyLimit?: number;
|
||||
enabled?: boolean;
|
||||
proxyUrl?: string;
|
||||
};
|
||||
|
||||
export function adminChannelSavePayload(values: ChannelFormValues) {
|
||||
@@ -37,6 +38,7 @@ export function adminChannelSavePayload(values: ChannelFormValues) {
|
||||
useGlobalConcurrency: values.useGlobalConcurrency !== false,
|
||||
concurrencyLimit: values.useGlobalConcurrency === false ? values.concurrencyLimit : undefined,
|
||||
enabled: values.enabled !== false,
|
||||
proxyUrl: values.proxyUrl?.trim() || "",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -129,8 +131,9 @@ export default function ChannelsPage() {
|
||||
useGlobalConcurrency: !channel.concurrencyLimit,
|
||||
concurrencyLimit: channel.concurrencyLimit || undefined,
|
||||
enabled: channel.enabled !== false,
|
||||
proxyUrl: channel.proxyUrl || "",
|
||||
}
|
||||
: { name: "", baseUrl: "", apiKey: "", secretKey: "", headers: [], useGlobalConcurrency: true, concurrencyLimit: undefined, enabled: true },
|
||||
: { name: "", baseUrl: "", apiKey: "", secretKey: "", headers: [], useGlobalConcurrency: true, concurrencyLimit: undefined, enabled: true, proxyUrl: "" },
|
||||
);
|
||||
setDrawerOpen(true);
|
||||
};
|
||||
@@ -393,6 +396,13 @@ export default function ChannelsPage() {
|
||||
<ChannelHeadersEditor />
|
||||
</Form.Item>
|
||||
</div>
|
||||
<Form.Item
|
||||
name="proxyUrl"
|
||||
label="代理地址"
|
||||
extra="为该渠道单独配置出站代理,支持 socks5://、http://、https://。留空则使用全局环境变量代理。"
|
||||
>
|
||||
<Input placeholder="例如:socks5://127.0.0.1:1080 或 http://127.0.0.1:7890" />
|
||||
</Form.Item>
|
||||
<Form.Item name="useGlobalConcurrency" label="跟随系统并发配置" valuePropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
|
||||
@@ -88,6 +88,7 @@ export type ModelChannel = {
|
||||
hasApiKey?: boolean;
|
||||
hasSecretKey?: boolean;
|
||||
concurrencyLimit?: number;
|
||||
proxyUrl?: string;
|
||||
modelCosts?: Array<{
|
||||
model: string;
|
||||
displayName?: string;
|
||||
|
||||
Reference in New Issue
Block a user