2026-11-10 01:24:28 +08:00
|
|
|
|
package client
|
|
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
|
"context"
|
|
|
|
|
|
"encoding/json"
|
|
|
|
|
|
"fmt"
|
|
|
|
|
|
"io"
|
|
|
|
|
|
"net/http"
|
|
|
|
|
|
"strings"
|
|
|
|
|
|
"time"
|
|
|
|
|
|
|
2026-11-10 09:59:34 +08:00
|
|
|
|
"cursor/internal/logger"
|
2026-11-10 01:24:28 +08:00
|
|
|
|
"cursor/internal/modelchannel"
|
2026-11-10 09:59:34 +08:00
|
|
|
|
"cursor/internal/netproxy"
|
2026-11-10 01:24:28 +08:00
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
// RemoteModel 表示从模型服务 /models 端点拉取到的一个模型。
|
|
|
|
|
|
type RemoteModel struct {
|
|
|
|
|
|
ID string `json:"id"`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// RemoteModelsResult 表示一次模型列表拉取结果。
|
|
|
|
|
|
type RemoteModelsResult struct {
|
|
|
|
|
|
Models []RemoteModel `json:"models"`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const remoteModelsFetchTimeout = 30 * time.Second
|
|
|
|
|
|
|
|
|
|
|
|
// ListRemoteModels 通过 openai/anthropic 兼容的 /v1/models 端点拉取模型列表。
|
|
|
|
|
|
func (s *ProxyService) ListRemoteModels(adapterType string, baseURL string, apiKey string) (RemoteModelsResult, error) {
|
|
|
|
|
|
result := RemoteModelsResult{Models: []RemoteModel{}}
|
|
|
|
|
|
if s == nil {
|
|
|
|
|
|
return result, fmt.Errorf("服务未初始化")
|
|
|
|
|
|
}
|
|
|
|
|
|
adapterType = strings.ToLower(strings.TrimSpace(adapterType))
|
|
|
|
|
|
baseURL = strings.TrimSpace(baseURL)
|
|
|
|
|
|
apiKey = strings.TrimSpace(apiKey)
|
2026-11-10 09:59:34 +08:00
|
|
|
|
logger.RegisterSecrets(apiKey)
|
2026-11-10 01:24:28 +08:00
|
|
|
|
if adapterType != "openai" && adapterType != "anthropic" {
|
|
|
|
|
|
return result, fmt.Errorf("模型类型仅支持 openai 或 anthropic")
|
|
|
|
|
|
}
|
|
|
|
|
|
if baseURL == "" {
|
|
|
|
|
|
return result, fmt.Errorf("接口地址不能为空")
|
|
|
|
|
|
}
|
|
|
|
|
|
if apiKey == "" {
|
|
|
|
|
|
return result, fmt.Errorf("访问密钥不能为空")
|
|
|
|
|
|
}
|
|
|
|
|
|
normalizedBaseURL, err := modelchannel.NormalizeBaseURL(baseURL)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return result, err
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
endpoint := buildModelsEndpoint(adapterType, normalizedBaseURL)
|
|
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), remoteModelsFetchTimeout)
|
|
|
|
|
|
defer cancel()
|
|
|
|
|
|
|
|
|
|
|
|
request, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return result, fmt.Errorf("构建模型列表请求失败: %w", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
if adapterType == "openai" {
|
|
|
|
|
|
request.Header.Set("Authorization", "Bearer "+apiKey)
|
|
|
|
|
|
} else {
|
|
|
|
|
|
request.Header.Set("x-api-key", apiKey)
|
|
|
|
|
|
request.Header.Set("anthropic-version", "2023-06-01")
|
|
|
|
|
|
}
|
|
|
|
|
|
request.Header.Set("Accept", "application/json")
|
|
|
|
|
|
|
2026-11-10 09:59:34 +08:00
|
|
|
|
response, err := netproxy.CredentialSafeClient(http.DefaultClient).Do(request)
|
2026-11-10 01:24:28 +08:00
|
|
|
|
if err != nil {
|
|
|
|
|
|
return result, fmt.Errorf("请求模型列表失败: %w", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
defer response.Body.Close()
|
|
|
|
|
|
|
|
|
|
|
|
body, err := io.ReadAll(io.LimitReader(response.Body, 8<<20))
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return result, fmt.Errorf("读取模型列表响应失败: %w", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
if response.StatusCode < 200 || response.StatusCode >= 300 {
|
|
|
|
|
|
return result, fmt.Errorf("模型列表接口返回 %d: %s", response.StatusCode, strings.TrimSpace(string(body)))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
models, err := decodeRemoteModels(adapterType, body)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return result, err
|
|
|
|
|
|
}
|
|
|
|
|
|
result.Models = models
|
|
|
|
|
|
return result, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// buildModelsEndpoint 把规范化后的 baseURL 解析为模型列表端点,
|
|
|
|
|
|
// 与聊天转发的 URL 解析保持同样的幂等口径:
|
|
|
|
|
|
// - baseURL 以 /v1 结尾:仅追加 /models,避免拼出 /v1/v1/models
|
|
|
|
|
|
// - openai baseURL 内嵌完整聊天端点(/v1/responses、/v1/chat/completions):仅剥离方法段,端点回退为 /v1/models
|
|
|
|
|
|
// - 其余情况(裸 host、官方 anthropic 地址):追加 /v1/models
|
|
|
|
|
|
func buildModelsEndpoint(adapterType string, normalizedBaseURL string) string {
|
|
|
|
|
|
base := strings.TrimRight(normalizedBaseURL, "/")
|
|
|
|
|
|
lower := strings.ToLower(base)
|
|
|
|
|
|
if adapterType == "openai" {
|
|
|
|
|
|
for _, endpoint := range []string{modelchannel.OpenAIEndpointChatCompletions, modelchannel.OpenAIEndpointResponses} {
|
|
|
|
|
|
methodPath := strings.TrimPrefix(strings.ToLower(endpoint), "/v1")
|
|
|
|
|
|
if methodPath != "" && strings.HasSuffix(lower, methodPath) {
|
|
|
|
|
|
return base[:len(base)-len(methodPath)] + "/models"
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if strings.HasSuffix(lower, "/v1") {
|
|
|
|
|
|
return base + "/models"
|
|
|
|
|
|
}
|
|
|
|
|
|
return base + "/v1/models"
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func decodeRemoteModels(adapterType string, body []byte) ([]RemoteModel, error) {
|
|
|
|
|
|
var payload struct {
|
|
|
|
|
|
Data []struct {
|
|
|
|
|
|
ID string `json:"id"`
|
|
|
|
|
|
} `json:"data"`
|
|
|
|
|
|
}
|
|
|
|
|
|
if err := json.Unmarshal(body, &payload); err != nil {
|
|
|
|
|
|
return nil, fmt.Errorf("解析模型列表响应失败: %w", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
seen := make(map[string]struct{}, len(payload.Data))
|
|
|
|
|
|
models := make([]RemoteModel, 0, len(payload.Data))
|
|
|
|
|
|
for _, item := range payload.Data {
|
|
|
|
|
|
id := strings.TrimSpace(item.ID)
|
|
|
|
|
|
if id == "" {
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
if _, exists := seen[id]; exists {
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
seen[id] = struct{}{}
|
|
|
|
|
|
models = append(models, RemoteModel{ID: id})
|
|
|
|
|
|
}
|
|
|
|
|
|
if len(models) == 0 {
|
|
|
|
|
|
return nil, fmt.Errorf("模型列表为空,请检查接口地址与密钥")
|
|
|
|
|
|
}
|
|
|
|
|
|
return models, nil
|
|
|
|
|
|
}
|