* feat(proxy): 实现第一阶段数据面代理 * feat(proxy): 收敛渠道与代理协议支持 * fix(proxy): 修复环境检测与迁移验证 * fix(proxy): 回收旧代理运行时并校验地址 * fix(proxy): 补齐创建链路与前端校验 * fix(proxy): 收口订阅代理与迁移兼容 * fix(proxy): 修复环境代理与运行时回收 * fix(proxy): 为系统外部请求应用全局代理 * docs(config): 精简环境配置说明 * feat(proxy): 重构代理设置的前端交互与样式 设置页与分组设置页: - 代理设置接入页面统一的草稿态与保存栏,不再独立即时提交 - 计入页面 dirty/valid,参与未保存离开拦截与并发冲突推迟 - 新增 ProxyOverrideRow,用分段控件替代原独立编辑弹层 账号卡片与密钥行: - ProxyConfigEditor 新增 card 外观:标题在白底面板外,与相邻小节同级 - 修掉按视口宽度判断断点的问题,窄容器内不再挤压错乱 - 编辑态下拉固定宽度,不随所选模式伸缩;整体收进两行 - 新增 ProxyScopeIndicator,凭据配置了独立代理时给出标识与悬停提示 密钥行交互: - 操作列收成「更多操作」菜单与展开图标,与账号卡片同构 - 折叠区分为权重、出站代理、运行诊断三个带标题小节,设置与诊断不再混放 - 权重编辑从弹层迁入折叠区,权重列的值可点击直达 - 释放的列宽还给凭据、权重与最近调用列 文案精简为继承、直连、自定义,并清理两处无引用的死键 * refactor(proxy): 统一代理设置的覆盖交互并消除样式重复 设置页与分组设置页: - 代理行改用 RuntimeOverrideRow,与超时等设置项共用同一组件 - inherit 映射为“未覆盖”,direct/custom 映射为“显式覆盖”,因此补齐了 覆盖/恢复默认按钮、覆盖状态徽章、当前有效值与待恢复默认态 - 分段控件只保留直连与自定义,继承由外层覆盖开关表达 - 删除自造的 ProxyOverrideRow,新增只含编辑控件的 ProxyOverrideControl 已保存的自定义地址: - 输入框不做回填,统一由 placeholder 展示已存地址 - 同模式下留空或与已存地址逐字相同均视为未改动,顺带挡住掩码被当作 新密码存回;无改动时保存按钮禁用 - 重新开启覆盖时回到基线已存的覆盖模式,使恢复默认可原路撤销 代码质量: - 面板外壳(标题 + 白底面板 + 标签/值/编辑图标/操作)抽为全局 .setting-panel,代理、权重、诊断三处共用,不再各写一份 - 删除 ProxyConfigEditor 已无调用方的 row 外观及随之失效的 props、 徽章分支与始终隐藏的文案 - 占位地址推导与覆盖开关规则收敛为 proxy.ts 中的共享函数 - 移除代理输入提示文案及其失去引用的 i18n 键 密钥行折叠区诊断值字号对齐上方面板。重构以构建产物 CSS 逐条比对验证, 差异均为已论证的等价改写。 * fix(proxy): 修复代理传输与校验边界
400 lines
12 KiB
Go
400 lines
12 KiB
Go
package control
|
|
|
|
import (
|
|
"context"
|
|
cryptorand "crypto/rand"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"gpt-load/internal/catalog"
|
|
"gpt-load/internal/channel"
|
|
"gpt-load/internal/execution"
|
|
"gpt-load/internal/outboundproxy"
|
|
app_errors "gpt-load/internal/platform/errors"
|
|
"gpt-load/internal/pricing"
|
|
"gpt-load/internal/protocol"
|
|
"gpt-load/internal/state"
|
|
)
|
|
|
|
const (
|
|
maxModelDiscoveryPages = 20
|
|
maxModelDiscoveryModels = 20_000
|
|
modelDiscoveryPageSize = 1000
|
|
)
|
|
|
|
type discoveryCredential struct {
|
|
snapshot execution.CredentialSnapshot
|
|
apiKey string
|
|
proxy outboundproxy.Effective
|
|
proxyFingerprint string
|
|
}
|
|
|
|
type discoveryTarget struct {
|
|
channelID channel.ID
|
|
resolvedTarget channel.ResolvedTarget
|
|
credentials []discoveryCredential
|
|
headerRules state.HeaderRules
|
|
timeouts state.TimeoutConfig
|
|
catalogProviderID string
|
|
}
|
|
|
|
func (s *Service) executeModelDiscovery(
|
|
ctx context.Context,
|
|
target discoveryTarget,
|
|
) (ModelDiscoveryResult, error) {
|
|
if err := ctx.Err(); err != nil {
|
|
return ModelDiscoveryResult{}, err
|
|
}
|
|
if s == nil || s.executor == nil {
|
|
return ModelDiscoveryResult{}, app_errors.ErrInternalServer
|
|
}
|
|
if target.channelID == "" ||
|
|
target.resolvedTarget.ChannelID != target.channelID || len(target.credentials) == 0 {
|
|
return ModelDiscoveryResult{}, app_errors.ErrValidation
|
|
}
|
|
clientProtocol, routeMode, supported := target.resolvedTarget.PreferredRoute(execution.OperationListModels, "")
|
|
if !supported {
|
|
return ModelDiscoveryResult{}, app_errors.ErrValidation
|
|
}
|
|
clientProtocol, method, path, body, err := utilityRequestShape(clientProtocol, execution.OperationListModels)
|
|
if err != nil {
|
|
return ModelDiscoveryResult{}, err
|
|
}
|
|
|
|
discoveryCtx, cancel := context.WithTimeout(ctx, s.modelDiscoveryTimeout)
|
|
defer cancel()
|
|
requestID, err := s.newExecutionID()
|
|
if err != nil {
|
|
return ModelDiscoveryResult{}, fmt.Errorf("create discovery request identity: %w", app_errors.ErrInternalServer)
|
|
}
|
|
var sequence uint32
|
|
for _, credential := range target.credentials {
|
|
models := make([]string, 0)
|
|
rawQuery := modelDiscoveryInitialQuery(clientProtocol)
|
|
seenCursors := make(map[string]struct{})
|
|
for pageNumber := 0; pageNumber < maxModelDiscoveryPages; pageNumber++ {
|
|
attemptID, identityErr := s.newExecutionID()
|
|
if identityErr != nil {
|
|
return ModelDiscoveryResult{}, fmt.Errorf("create discovery attempt identity: %w", app_errors.ErrInternalServer)
|
|
}
|
|
sequence++
|
|
spec := execution.NewAttemptSpec(execution.AttemptSpec{
|
|
RequestID: requestID, AttemptID: attemptID, Sequence: sequence,
|
|
ChannelID: string(target.channelID),
|
|
RouteMode: execution.RouteMode(routeMode), ClientProtocol: clientProtocol,
|
|
Operation: execution.OperationListModels, Method: method, Path: path,
|
|
RawQuery: rawQuery,
|
|
Header: applyControlHeaderRules(target.headerRules, credential.apiKey), Body: body,
|
|
TargetConfig: target.resolvedTarget.TargetConfig,
|
|
Timeouts: executionTimeouts(target.timeouts),
|
|
Credential: credential.snapshot,
|
|
Proxy: credential.proxy,
|
|
ProxyFingerprint: credential.proxyFingerprint,
|
|
})
|
|
if validationErr := spec.Validate(); validationErr != nil {
|
|
return ModelDiscoveryResult{}, fmt.Errorf("build discovery attempt: %w", app_errors.ErrInternalServer)
|
|
}
|
|
result := s.executor.Execute(discoveryCtx, spec)
|
|
if parentErr := ctx.Err(); parentErr != nil {
|
|
return ModelDiscoveryResult{}, parentErr
|
|
}
|
|
if errors.Is(discoveryCtx.Err(), context.DeadlineExceeded) {
|
|
return ModelDiscoveryResult{}, fmt.Errorf("discover upstream models: %w", app_errors.ErrBadGateway)
|
|
}
|
|
if result.Validate() != nil || result.Error != nil ||
|
|
result.StatusCode < http.StatusOK || result.StatusCode >= http.StatusMultipleChoices {
|
|
break
|
|
}
|
|
discoveredPage, parseErr := parseDiscoveredModelsPage(clientProtocol, result.Body)
|
|
if parseErr != nil {
|
|
break
|
|
}
|
|
models = append(models, discoveredPage.models...)
|
|
if len(models) > maxModelDiscoveryModels {
|
|
break
|
|
}
|
|
if discoveredPage.nextRawQuery == "" {
|
|
return s.mergeDiscoveredModels(
|
|
discoveryCtx,
|
|
normalizeDiscoveredModels(models),
|
|
target,
|
|
)
|
|
}
|
|
if _, repeated := seenCursors[discoveredPage.nextRawQuery]; repeated {
|
|
break
|
|
}
|
|
seenCursors[discoveredPage.nextRawQuery] = struct{}{}
|
|
rawQuery = discoveredPage.nextRawQuery
|
|
}
|
|
}
|
|
if parentErr := ctx.Err(); parentErr != nil {
|
|
return ModelDiscoveryResult{}, parentErr
|
|
}
|
|
return ModelDiscoveryResult{}, fmt.Errorf("discover upstream models: %w", app_errors.ErrBadGateway)
|
|
}
|
|
|
|
func (s *Service) newExecutionID() (string, error) {
|
|
random := io.Reader(cryptorand.Reader)
|
|
if s != nil && s.random != nil {
|
|
random = s.random
|
|
}
|
|
return newOperationID(random)
|
|
}
|
|
|
|
func executionTimeouts(value state.TimeoutConfig) execution.AttemptTimeouts {
|
|
return execution.AttemptTimeouts{
|
|
FirstByte: value.FirstByte,
|
|
Request: value.Request, StreamIdle: value.StreamIdle,
|
|
}
|
|
}
|
|
|
|
func applyControlHeaderRules(rules state.HeaderRules, apiKey string) http.Header {
|
|
headers := make(http.Header, len(rules.Set))
|
|
for name, value := range rules.Set {
|
|
headers.Set(name, strings.ReplaceAll(value, "${API_KEY}", apiKey))
|
|
}
|
|
for _, name := range rules.Remove {
|
|
headers.Del(name)
|
|
}
|
|
headers.Set("Accept-Encoding", "identity")
|
|
return headers
|
|
}
|
|
|
|
func utilityRequestShape(
|
|
clientProtocol protocol.Protocol,
|
|
operation execution.Operation,
|
|
) (protocol.Protocol, string, string, []byte, error) {
|
|
switch operation {
|
|
case execution.OperationListModels:
|
|
switch clientProtocol {
|
|
case protocol.OpenAICompletions, protocol.Anthropic:
|
|
return clientProtocol, http.MethodGet, "/v1/models", nil, nil
|
|
case protocol.Gemini:
|
|
return clientProtocol, http.MethodGet, "/v1beta/models", nil, nil
|
|
default:
|
|
return "", "", "", nil, app_errors.ErrValidation
|
|
}
|
|
}
|
|
return "", "", "", nil, app_errors.ErrValidation
|
|
}
|
|
|
|
type discoveredModelsPage struct {
|
|
models []string
|
|
nextRawQuery string
|
|
}
|
|
|
|
func parseDiscoveredModelsPage(
|
|
clientProtocol protocol.Protocol,
|
|
body []byte,
|
|
) (discoveredModelsPage, error) {
|
|
switch clientProtocol {
|
|
case protocol.OpenAICompletions:
|
|
var payload struct {
|
|
Data []struct {
|
|
ID string `json:"id"`
|
|
} `json:"data"`
|
|
}
|
|
if err := decodeSingleJSON(body, &payload); err != nil {
|
|
return discoveredModelsPage{}, err
|
|
}
|
|
models := make([]string, 0, len(payload.Data))
|
|
for _, item := range payload.Data {
|
|
models = append(models, item.ID)
|
|
}
|
|
return discoveredModelsPage{models: models}, nil
|
|
case protocol.Anthropic:
|
|
var payload struct {
|
|
Data []struct {
|
|
ID string `json:"id"`
|
|
} `json:"data"`
|
|
HasMore bool `json:"has_more"`
|
|
LastID string `json:"last_id"`
|
|
}
|
|
if err := decodeSingleJSON(body, &payload); err != nil {
|
|
return discoveredModelsPage{}, err
|
|
}
|
|
models := make([]string, 0, len(payload.Data))
|
|
for _, item := range payload.Data {
|
|
models = append(models, item.ID)
|
|
}
|
|
query, err := nextModelDiscoveryQuery(
|
|
clientProtocol,
|
|
payload.HasMore,
|
|
"after_id",
|
|
payload.LastID,
|
|
)
|
|
if err != nil {
|
|
return discoveredModelsPage{}, err
|
|
}
|
|
return discoveredModelsPage{models: models, nextRawQuery: query}, nil
|
|
case protocol.Gemini:
|
|
var payload struct {
|
|
Models []struct {
|
|
Name string `json:"name"`
|
|
} `json:"models"`
|
|
NextPageToken string `json:"nextPageToken"`
|
|
}
|
|
if err := decodeSingleJSON(body, &payload); err != nil {
|
|
return discoveredModelsPage{}, err
|
|
}
|
|
models := make([]string, 0, len(payload.Models))
|
|
for _, item := range payload.Models {
|
|
models = append(models, strings.TrimPrefix(item.Name, "models/"))
|
|
}
|
|
query, err := nextModelDiscoveryQuery(
|
|
clientProtocol,
|
|
payload.NextPageToken != "",
|
|
"pageToken",
|
|
payload.NextPageToken,
|
|
)
|
|
if err != nil {
|
|
return discoveredModelsPage{}, err
|
|
}
|
|
return discoveredModelsPage{models: models, nextRawQuery: query}, nil
|
|
default:
|
|
return discoveredModelsPage{}, app_errors.ErrValidation
|
|
}
|
|
}
|
|
|
|
func modelDiscoveryInitialQuery(clientProtocol protocol.Protocol) string {
|
|
values := make(url.Values, 1)
|
|
switch clientProtocol {
|
|
case protocol.Anthropic:
|
|
values.Set("limit", strconv.Itoa(modelDiscoveryPageSize))
|
|
case protocol.Gemini:
|
|
values.Set("pageSize", strconv.Itoa(modelDiscoveryPageSize))
|
|
}
|
|
return values.Encode()
|
|
}
|
|
|
|
func nextModelDiscoveryQuery(
|
|
clientProtocol protocol.Protocol,
|
|
hasMore bool,
|
|
key string,
|
|
cursor string,
|
|
) (string, error) {
|
|
if !hasMore {
|
|
return "", nil
|
|
}
|
|
cursor = strings.TrimSpace(cursor)
|
|
if cursor == "" {
|
|
return "", fmt.Errorf("model list pagination cursor is missing")
|
|
}
|
|
values, err := url.ParseQuery(modelDiscoveryInitialQuery(clientProtocol))
|
|
if err != nil {
|
|
return "", fmt.Errorf("build model list pagination query")
|
|
}
|
|
values.Set(key, cursor)
|
|
return values.Encode(), nil
|
|
}
|
|
|
|
func decodeSingleJSON(body []byte, target any) error {
|
|
decoder := json.NewDecoder(strings.NewReader(string(body)))
|
|
if err := decoder.Decode(target); err != nil {
|
|
return err
|
|
}
|
|
var trailing any
|
|
if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) {
|
|
if err == nil {
|
|
return fmt.Errorf("multiple JSON values")
|
|
}
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Service) mergeDiscoveredModels(
|
|
ctx context.Context,
|
|
live []string,
|
|
target discoveryTarget,
|
|
) (ModelDiscoveryResult, error) {
|
|
var catalogSnapshot *catalog.Snapshot
|
|
if s.catalogRuntime != nil {
|
|
catalogSnapshot = s.catalogRuntime.Load()
|
|
}
|
|
var providerModels map[string]catalog.Model
|
|
if target.catalogProviderID != "" && catalogSnapshot != nil {
|
|
if provider, exists := catalogSnapshot.Providers[target.catalogProviderID]; exists {
|
|
providerModels = provider.Models
|
|
}
|
|
}
|
|
rows := modelPriceRows{}
|
|
if s.db != nil {
|
|
loaded, err := loadModelPriceRows(ctx, s.db)
|
|
if err != nil {
|
|
return ModelDiscoveryResult{}, err
|
|
}
|
|
rows = loaded
|
|
}
|
|
|
|
result := make([]ModelCandidate, 0, len(live)+len(providerModels))
|
|
seen := make(map[string]int, len(live)+len(providerModels))
|
|
for _, id := range live {
|
|
model, catalogMatch := providerModels[id]
|
|
identity := pricing.Identity{ChannelID: string(target.channelID), ModelID: id}
|
|
pricingStatus, pricingSource := resolveCandidatePricing(rows[identity], catalogSnapshot, identity)
|
|
candidate := ModelCandidate{
|
|
ID: id, Name: id, Sources: []string{"live"},
|
|
PricingStatus: pricingStatus, PricingSource: pricingSource,
|
|
}
|
|
if catalogMatch {
|
|
if name := strings.TrimSpace(model.Name); name != "" {
|
|
candidate.Name = name
|
|
}
|
|
candidate.Sources = append(candidate.Sources, "catalog")
|
|
}
|
|
seen[id] = len(result)
|
|
result = append(result, candidate)
|
|
}
|
|
catalogOnly := make([]ModelCandidate, 0)
|
|
for id, model := range providerModels {
|
|
if _, duplicate := seen[id]; duplicate {
|
|
continue
|
|
}
|
|
name := strings.TrimSpace(model.Name)
|
|
if name == "" {
|
|
name = id
|
|
}
|
|
identity := pricing.Identity{ChannelID: string(target.channelID), ModelID: id}
|
|
pricingStatus, pricingSource := resolveCandidatePricing(rows[identity], catalogSnapshot, identity)
|
|
catalogOnly = append(catalogOnly, ModelCandidate{
|
|
ID: id, Name: name, Sources: []string{"catalog"},
|
|
PricingStatus: pricingStatus, PricingSource: pricingSource,
|
|
})
|
|
}
|
|
sort.Slice(catalogOnly, func(left, right int) bool {
|
|
leftName := strings.ToLower(catalogOnly[left].Name)
|
|
rightName := strings.ToLower(catalogOnly[right].Name)
|
|
if leftName == rightName {
|
|
return catalogOnly[left].ID < catalogOnly[right].ID
|
|
}
|
|
return leftName < rightName
|
|
})
|
|
result = append(result, catalogOnly...)
|
|
return ModelDiscoveryResult{Models: result}, nil
|
|
}
|
|
|
|
func normalizeDiscoveredModels(values []string) []string {
|
|
result := make([]string, 0, len(values))
|
|
seen := make(map[string]struct{}, len(values))
|
|
for _, value := range values {
|
|
normalized := strings.TrimSpace(value)
|
|
if normalized == "" {
|
|
continue
|
|
}
|
|
if _, duplicate := seen[normalized]; duplicate {
|
|
continue
|
|
}
|
|
seen[normalized] = struct{}{}
|
|
result = append(result, normalized)
|
|
}
|
|
return result
|
|
}
|