feat(model-adapter): add remote model selection

- Fetch and normalize OpenAI and Anthropic model lists through the proxy service.
- Add searchable multi-model selection with localized UI and persisted adapter mappings.
- Cover endpoint resolution, authentication, pagination, and response parsing.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
xiaopu
2026-08-05 15:23:51 +08:00
co-authored by Cursor
parent 639c452a00
commit 320b9fb7b9
12 changed files with 1680 additions and 172 deletions
+303
View File
@@ -10,6 +10,7 @@ import (
"io"
"math"
"net/http"
"net/url"
"sort"
"strconv"
"strings"
@@ -31,8 +32,48 @@ const (
modelAdapterTestDefaultMaxTokens = 65_536
modelAdapterTestEmptyTextError = "未收到文本输出,无法计算测速结果"
modelAdapterTestMaxErrorBodyBytes = 8192
modelAdapterListTimeout = 20 * time.Second
modelAdapterListMaxBodyBytes = 8 << 20
modelAdapterListPageSize = 1000
modelAdapterListMaxPages = 50
)
// modelListProviderRule 收敛各家模型列表接口的协议差异,避免判断散落到多个函数。
type modelListProviderRule struct {
// paths 按优先级排列,逐个尝试直到某个返回可用模型
paths []string
authHeader string
authPrefix string
extraHeader map[string]string
// paginated 为真时按 limit + after_id 游标翻页,直到 has_more 为 false
paginated bool
}
var modelListProviderRules = map[string]modelListProviderRule{
"openai": {
paths: []string{"/models"},
authHeader: "Authorization",
authPrefix: "Bearer ",
},
"anthropic": {
paths: []string{"/models"},
authHeader: "x-api-key",
extraHeader: map[string]string{"anthropic-version": "2023-06-01"},
paginated: true,
},
}
// modelListVersionSegments 用于判断 base url 是否已带版本前缀,带了就不再补 /v1。
var modelListVersionSegments = map[string]bool{
"v1": true,
"v1beta": true,
"v2": true,
"beta": true,
"openai": true,
"compat": true,
"compatible": true,
}
type ModelAdapterTestStatus string
const (
@@ -58,6 +99,20 @@ type ModelAdapterTestResult struct {
TestedAt string `json:"testedAt"`
}
// ModelAdapterModelsRequest 定义从兼容接口读取模型列表所需的最小配置。
type ModelAdapterModelsRequest struct {
Type string `json:"type"`
BaseURL string `json:"baseURL"`
APIKey string `json:"apiKey"`
CustomHeadersEnabled bool `json:"customHeadersEnabled"`
CustomHeadersJSON string `json:"customHeadersJSON"`
}
// ModelAdapterModelsResult 定义可供前端下拉选择的模型列表。
type ModelAdapterModelsResult struct {
Models []string `json:"models"`
}
// ModelAdapterTestResultsPayload 用于向前端广播当前测速结果快照。
type ModelAdapterTestResultsPayload struct {
Results []ModelAdapterTestResult `json:"results"`
@@ -108,6 +163,254 @@ func (s *ProxyService) GetModelAdapterTestResults() []ModelAdapterTestResult {
return s.snapshotModelAdapterTestResults()
}
func (s *ProxyService) FetchModelAdapterModels(input ModelAdapterModelsRequest) (ModelAdapterModelsResult, error) {
_ = s
provider := strings.ToLower(strings.TrimSpace(input.Type))
baseURL := strings.TrimSpace(input.BaseURL)
apiKey := strings.TrimSpace(input.APIKey)
rule, supported := modelListProviderRules[provider]
if !supported {
return ModelAdapterModelsResult{}, errors.New("模型类型仅支持 OpenAI 或 Anthropic")
}
if baseURL == "" {
return ModelAdapterModelsResult{}, errors.New("接口地址不能为空")
}
if apiKey == "" {
return ModelAdapterModelsResult{}, errors.New("访问密钥不能为空")
}
ctx, cancel := context.WithTimeout(context.Background(), modelAdapterListTimeout)
defer cancel()
var lastErr error
for _, endpoint := range buildModelListEndpointCandidates(rule, baseURL) {
models, err := fetchModelListEndpoint(ctx, rule, endpoint, apiKey, input)
if err == nil {
return ModelAdapterModelsResult{Models: models}, nil
}
lastErr = err
}
if lastErr != nil {
return ModelAdapterModelsResult{}, lastErr
}
return ModelAdapterModelsResult{}, errors.New("未找到可用的模型列表接口")
}
func buildModelListEndpointCandidates(rule modelListProviderRule, rawBaseURL string) []string {
base := strings.TrimRight(strings.TrimSpace(rawBaseURL), "/")
for _, suffix := range []string{"/chat/completions", "/responses", "/messages"} {
if strings.HasSuffix(strings.ToLower(base), suffix) {
base = base[:len(base)-len(suffix)]
}
}
base = strings.TrimRight(base, "/")
tail := strings.ToLower(base[strings.LastIndex(base, "/")+1:])
var candidates []string
switch {
case tail == "models" || tail == "model":
// 用户已经填到模型列表地址本身,直接用
candidates = []string{base}
case modelListVersionSegments[tail]:
candidates = prefixModelListPaths(base, "", rule.paths)
default:
// base 没带版本段,优先试 /v1,再退回裸路径
candidates = append(
prefixModelListPaths(base, "/v1", rule.paths),
prefixModelListPaths(base, "", rule.paths)...,
)
}
seen := map[string]struct{}{}
endpoints := make([]string, 0, len(candidates))
for _, endpoint := range candidates {
if _, err := url.ParseRequestURI(endpoint); err != nil {
continue
}
if _, exists := seen[endpoint]; exists {
continue
}
seen[endpoint] = struct{}{}
endpoints = append(endpoints, endpoint)
}
return endpoints
}
func prefixModelListPaths(base string, version string, paths []string) []string {
endpoints := make([]string, 0, len(paths))
for _, path := range paths {
endpoints = append(endpoints, base+version+path)
}
return endpoints
}
func fetchModelListEndpoint(ctx context.Context, rule modelListProviderRule, endpoint string, apiKey string, input ModelAdapterModelsRequest) ([]string, error) {
collected := []string{}
cursor := ""
for page := 0; page < modelAdapterListMaxPages; page++ {
requestURL := endpoint
if rule.paginated {
requestURL = appendModelListCursor(endpoint, cursor)
}
payload, err := requestModelListPayload(ctx, rule, requestURL, apiKey, input)
if err != nil {
return nil, err
}
collected = append(collected, extractModelIDs(payload)...)
if !rule.paginated {
break
}
cursor = nextModelListCursor(payload)
if cursor == "" {
break
}
if page == modelAdapterListMaxPages-1 {
return nil, fmt.Errorf("模型列表分页超过 %d 页,结果可能不完整", modelAdapterListMaxPages)
}
}
models := normalizeFetchedModelIDs(collected)
if len(models) == 0 {
return nil, errors.New("模型列表响应中没有可用模型")
}
return models, nil
}
func requestModelListPayload(
ctx context.Context,
rule modelListProviderRule,
requestURL string,
apiKey string,
input ModelAdapterModelsRequest,
) (any, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, requestURL, nil)
if err != nil {
return nil, err
}
req.Header.Set(rule.authHeader, rule.authPrefix+apiKey)
for key, value := range rule.extraHeader {
req.Header.Set(key, value)
}
req.Header.Set("Accept", "application/json")
applyModelListCustomHeaders(req.Header, input)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
body, readErr := io.ReadAll(io.LimitReader(resp.Body, modelAdapterListMaxBodyBytes))
if readErr != nil {
return nil, readErr
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
message := strings.TrimSpace(string(body))
if len(message) > modelAdapterTestMaxErrorBodyBytes {
message = message[:modelAdapterTestMaxErrorBodyBytes]
}
if message == "" {
message = resp.Status
}
return nil, fmt.Errorf("读取模型列表失败:%s", message)
}
var payload any
if err := json.Unmarshal(body, &payload); err != nil {
return nil, fmt.Errorf("模型列表响应不是合法 JSON%w", err)
}
return payload, nil
}
func appendModelListCursor(endpoint string, cursor string) string {
query := url.Values{}
query.Set("limit", strconv.Itoa(modelAdapterListPageSize))
if cursor != "" {
query.Set("after_id", cursor)
}
separator := "?"
if strings.Contains(endpoint, "?") {
separator = "&"
}
return endpoint + separator + query.Encode()
}
func nextModelListCursor(payload any) string {
object, ok := payload.(map[string]any)
if !ok {
return ""
}
if hasMore, _ := object["has_more"].(bool); !hasMore {
return ""
}
cursor, _ := object["last_id"].(string)
return strings.TrimSpace(cursor)
}
func applyModelListCustomHeaders(header http.Header, input ModelAdapterModelsRequest) {
if !input.CustomHeadersEnabled || strings.TrimSpace(input.CustomHeadersJSON) == "" {
return
}
var parsed map[string]string
if err := json.Unmarshal([]byte(input.CustomHeadersJSON), &parsed); err != nil {
return
}
for key, value := range parsed {
if strings.TrimSpace(key) == "" {
continue
}
header.Set(key, value)
}
}
func extractModelIDs(value any) []string {
switch typed := value.(type) {
case string:
if strings.TrimSpace(typed) == "" {
return []string{}
}
return []string{typed}
case []any:
models := make([]string, 0, len(typed))
for _, item := range typed {
models = append(models, extractModelIDs(item)...)
}
return models
case map[string]any:
for _, key := range []string{"id", "name"} {
if text, ok := typed[key].(string); ok && strings.TrimSpace(text) != "" {
return []string{text}
}
}
models := []string{}
for _, key := range []string{"data", "models"} {
if child, ok := typed[key]; ok {
models = append(models, extractModelIDs(child)...)
}
}
return models
default:
return []string{}
}
}
func normalizeFetchedModelIDs(input []string) []string {
seen := map[string]struct{}{}
models := make([]string, 0, len(input))
for _, item := range input {
model := strings.TrimSpace(item)
if model == "" {
continue
}
if _, exists := seen[model]; exists {
continue
}
seen[model] = struct{}{}
models = append(models, model)
}
sort.Strings(models)
return models
}
func (s *ProxyService) TestModelAdapter(adapter serverconfig.ModelAdapterConfig) (ModelAdapterTestResult, error) {
requestHash := buildModelAdapterTestRequestHash(adapter)
adapterID := buildModelAdapterTestCacheKey(adapter, requestHash)