Files
Local-Cursor/internal/client/models_fetch.go

140 lines
4.4 KiB
Go
Raw Normal View History

package client
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
"cursor/internal/logger"
"cursor/internal/modelchannel"
"cursor/internal/netproxy"
)
// 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)
logger.RegisterSecrets(apiKey)
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")
response, err := netproxy.CredentialSafeClient(http.DefaultClient).Do(request)
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
}