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 }