mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-16 07:33:06 +08:00
264 lines
9.5 KiB
Go
264 lines
9.5 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"slices"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/tidwall/gjson"
|
|
)
|
|
|
|
// 上游模型目录中的协议能力。部分聚合平台(Command Code)在 {Chat Completions 基址}/models
|
|
// 的每个模型上给出 supported_endpoints,例如:
|
|
//
|
|
// {"id":"deepseek/deepseek-v4-flash","supported_endpoints":["/chat/completions","/responses"]}
|
|
// {"id":"claude-sonnet-4-6","supported_endpoints":["/messages"]}
|
|
//
|
|
// 按模型分流时据此判断模型是否支持入站协议:支持就同协议直通,省去一次协议转换
|
|
// (见 modelProtocolSet)。
|
|
//
|
|
// 缓存范围:所有目录均按账号和地址隔离,包括官方默认地址。目录可能随 Key、租户
|
|
// 或请求头不同,鉴权失败的刷新退避也不能影响其他账号(见 modelProtocolCatalogKey)。
|
|
//
|
|
// 刷新在后台进行:过期时继续使用旧目录。目录从未加载成功时,首批请求最多等待
|
|
// modelProtocolCatalogFirstLoadWait,各请求共用本轮刷新开始时的截止时间,并响应请求
|
|
// 取消。超时后同一轮刷新中的后续请求立即回落内置规则,失败退避期内不再等待。
|
|
|
|
const (
|
|
modelProtocolCatalogTTL = 30 * time.Minute
|
|
modelProtocolCatalogRetryBackoff = 5 * time.Minute
|
|
modelProtocolCatalogTimeout = 15 * time.Second
|
|
modelProtocolCatalogFirstLoadWait = 2 * time.Second
|
|
modelProtocolCatalogMaxBodyBytes = 2 << 20
|
|
)
|
|
|
|
type modelProtocolCatalogEntry struct {
|
|
// models 以小写模型 ID 为键,值为上游声明的协议(保持上游顺序)。
|
|
models map[string][]string
|
|
fetchedAt time.Time
|
|
retryAt time.Time
|
|
refreshing bool
|
|
// done 在进行中的刷新结束时关闭。
|
|
done chan struct{}
|
|
// firstLoadDeadline 是本轮冷启动刷新共享的等待截止时间。
|
|
firstLoadDeadline time.Time
|
|
}
|
|
|
|
type modelProtocolCatalog struct {
|
|
mu sync.Mutex
|
|
entries map[string]*modelProtocolCatalogEntry
|
|
}
|
|
|
|
// upstreamModelProtocols 是全进程共享的模型协议目录。
|
|
var upstreamModelProtocols modelProtocolCatalog
|
|
|
|
func (c *modelProtocolCatalog) entryLocked(key string) *modelProtocolCatalogEntry {
|
|
if c.entries == nil {
|
|
c.entries = make(map[string]*modelProtocolCatalogEntry)
|
|
}
|
|
entry := c.entries[key]
|
|
if entry == nil {
|
|
entry = &modelProtocolCatalogEntry{}
|
|
c.entries[key] = entry
|
|
}
|
|
return entry
|
|
}
|
|
|
|
// lookup 返回 key 目录中 model 支持的协议;目录尚未就绪或模型不在目录中时返回 nil。
|
|
// 目录缺失或过期、且不在刷新中也不在失败退避期时,异步调用 refresh(由其调用 store);
|
|
// 目录从未加载成功且刷新进行中时,只等待本轮冷启动窗口的剩余时间。
|
|
func (c *modelProtocolCatalog) lookup(ctx context.Context, key, model string, now time.Time, refresh func()) []string {
|
|
if ctx.Err() != nil {
|
|
return nil
|
|
}
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
entry := c.entryLocked(key)
|
|
stale := entry.models == nil || now.Sub(entry.fetchedAt) >= modelProtocolCatalogTTL
|
|
if stale && !entry.refreshing && !now.Before(entry.retryAt) && refresh != nil {
|
|
entry.refreshing = true
|
|
entry.done = make(chan struct{})
|
|
if entry.models == nil {
|
|
entry.firstLoadDeadline = now.Add(modelProtocolCatalogFirstLoadWait)
|
|
}
|
|
go refresh()
|
|
}
|
|
if entry.models == nil && entry.refreshing && now.Before(entry.firstLoadDeadline) {
|
|
done := entry.done
|
|
deadline := entry.firstLoadDeadline
|
|
c.mu.Unlock()
|
|
timer := time.NewTimer(time.Until(deadline))
|
|
select {
|
|
case <-done:
|
|
case <-timer.C:
|
|
case <-ctx.Done():
|
|
}
|
|
timer.Stop()
|
|
c.mu.Lock()
|
|
}
|
|
if entry.models == nil || ctx.Err() != nil {
|
|
return nil
|
|
}
|
|
return entry.models[strings.ToLower(strings.TrimSpace(model))]
|
|
}
|
|
|
|
// store 写入一次刷新的结果;失败时保留旧目录并进入退避。
|
|
func (c *modelProtocolCatalog) store(key string, models map[string][]string, err error, now time.Time) {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
entry := c.entryLocked(key)
|
|
entry.refreshing = false
|
|
if entry.done != nil {
|
|
close(entry.done)
|
|
entry.done = nil
|
|
}
|
|
if err != nil {
|
|
entry.retryAt = now.Add(modelProtocolCatalogRetryBackoff)
|
|
return
|
|
}
|
|
entry.models = models
|
|
entry.fetchedAt = now
|
|
entry.retryAt = time.Time{}
|
|
}
|
|
|
|
// modelEndpointProtocol 把 supported_endpoints 中的端点路径映射为上游协议。
|
|
func modelEndpointProtocol(endpoint string) string {
|
|
endpoint = strings.ToLower(strings.TrimRight(strings.TrimSpace(endpoint), "/"))
|
|
switch {
|
|
case strings.HasSuffix(endpoint, "/chat/completions"):
|
|
return APIProtocolChatCompletions
|
|
case strings.HasSuffix(endpoint, "/responses"):
|
|
return APIProtocolResponses
|
|
case strings.HasSuffix(endpoint, "/messages"):
|
|
return APIProtocolAnthropic
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
// parseModelProtocolCatalog 解析 /models 响应;没有任何模型带可识别的 supported_endpoints
|
|
// 时返回错误(上游不提供该字段,或是中转站的普通 /models)。
|
|
func parseModelProtocolCatalog(body []byte) (map[string][]string, error) {
|
|
entries, err := extractUpstreamModelRawEntries(body)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
models := make(map[string][]string)
|
|
for _, raw := range entries {
|
|
id := strings.ToLower(strings.TrimSpace(gjson.GetBytes(raw, "id").String()))
|
|
if id == "" {
|
|
continue
|
|
}
|
|
var protocols []string
|
|
for _, endpoint := range gjson.GetBytes(raw, "supported_endpoints").Array() {
|
|
protocol := modelEndpointProtocol(endpoint.String())
|
|
if protocol != "" && !slices.Contains(protocols, protocol) {
|
|
protocols = append(protocols, protocol)
|
|
}
|
|
}
|
|
if len(protocols) > 0 {
|
|
models[id] = protocols
|
|
}
|
|
}
|
|
if len(models) == 0 {
|
|
return nil, fmt.Errorf("model list has no supported_endpoints")
|
|
}
|
|
return models, nil
|
|
}
|
|
|
|
// modelCatalogProtocols 返回上游模型目录中 model 支持的协议;平台不提供目录、账号固定了
|
|
// 上游协议或目录尚未就绪时返回 nil。
|
|
func (s *OpenAIGatewayService) modelCatalogProtocols(ctx context.Context, account *Account, model string) []string {
|
|
if s == nil || s.httpUpstream == nil || strings.TrimSpace(model) == "" || !account.routesByModel() {
|
|
return nil
|
|
}
|
|
if profile := account.providerProfile(); profile == nil || !profile.ModelCatalog {
|
|
return nil
|
|
}
|
|
if account.GetAPIProtocol() != APIProtocolAdaptive {
|
|
return nil
|
|
}
|
|
base := strings.TrimSpace(account.GetCNProtocolBaseURL(APIProtocolChatCompletions))
|
|
if base == "" {
|
|
return nil
|
|
}
|
|
url := buildOpenAIModelsURL(base)
|
|
key := modelProtocolCatalogKey(account, url)
|
|
headers := modelProtocolCatalogHeaders(account, url)
|
|
proxyURL := ""
|
|
if account.ProxyID != nil && account.Proxy != nil {
|
|
proxyURL = account.Proxy.URL()
|
|
}
|
|
accountID, concurrency := account.ID, account.Concurrency
|
|
return upstreamModelProtocols.lookup(ctx, key, model, time.Now(), func() {
|
|
ctx, cancel := context.WithTimeout(context.Background(), modelProtocolCatalogTimeout)
|
|
defer cancel()
|
|
models, err := s.fetchModelProtocolCatalog(ctx, url, headers, proxyURL, accountID, concurrency)
|
|
if err != nil {
|
|
slog.Warn("model_protocol_catalog_refresh_failed", "account_id", accountID, "url", url, "error", err)
|
|
} else {
|
|
slog.Info("model_protocol_catalog_refreshed", "account_id", accountID, "url", url, "models", len(models))
|
|
}
|
|
upstreamModelProtocols.store(key, models, err, time.Now())
|
|
})
|
|
}
|
|
|
|
// modelProtocolCatalogKey 按账号和地址隔离目录及刷新退避,避免一个账号的目录或
|
|
// 鉴权失败影响同地址的其他账号。
|
|
func modelProtocolCatalogKey(account *Account, url string) string {
|
|
return fmt.Sprintf("%s#account=%d", url, account.ID)
|
|
}
|
|
|
|
// modelProtocolCatalogHeaders 构造目录请求头,UA 收敛与账号请求头覆写与转发一致
|
|
// (见 buildUpstreamRequest)。在请求时构造,后台刷新不再读取账号。
|
|
func modelProtocolCatalogHeaders(account *Account, url string) http.Header {
|
|
headers := http.Header{}
|
|
headers.Set("Accept", "application/json")
|
|
if apiKey := strings.TrimSpace(account.GetOpenAIProtocolAPIKey()); apiKey != "" {
|
|
headers.Set("Authorization", "Bearer "+apiKey)
|
|
}
|
|
applyOpenCodeUpstreamUserAgent(account, url, headers)
|
|
account.ApplyHeaderOverrides(headers)
|
|
return headers
|
|
}
|
|
|
|
func (s *OpenAIGatewayService) fetchModelProtocolCatalog(ctx context.Context, url string, headers http.Header, proxyURL string, accountID int64, concurrency int) (map[string][]string, error) {
|
|
validatedURL, err := s.validateUpstreamBaseURL(url)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
req, err := http.NewRequestWithContext(WithHTTPUpstreamRedirectsDisabled(ctx), http.MethodGet, validatedURL, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
req.Header = headers.Clone()
|
|
resp, err := s.httpUpstream.Do(req, proxyURL, accountID, maxInt(concurrency, 1))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
|
|
return nil, fmt.Errorf("model list returned HTTP %d", resp.StatusCode)
|
|
}
|
|
body, err := io.ReadAll(io.LimitReader(resp.Body, modelProtocolCatalogMaxBodyBytes+1))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(body) > modelProtocolCatalogMaxBodyBytes {
|
|
return nil, fmt.Errorf("model list exceeds %d bytes", modelProtocolCatalogMaxBodyBytes)
|
|
}
|
|
return parseModelProtocolCatalog(body)
|
|
}
|
|
|
|
// resolveUpstreamProtocolFor 是网关入口使用的 resolveUpstreamProtocol:按模型分流且平台
|
|
// 提供模型目录时,带上目录中该模型支持的协议。
|
|
func (s *OpenAIGatewayService) resolveUpstreamProtocolFor(ctx context.Context, account *Account, inbound, model string) string {
|
|
return resolveUpstreamProtocol(account, inbound, model, s.modelCatalogProtocols(ctx, account, model))
|
|
}
|