Files
gpt-load/internal/control/discover_executor.go
T
tbphp 4f9eba5802 feat(proxy): 完成数据面代理与渠道约束 (#459)
* 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): 修复代理传输与校验边界
2026-08-24 11:11:22 +08:00

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
}