Files
Local-Cursor/internal/client/models_fetch.go
FuDie0915 db73cf5a39 chore: import Local-Cursor v1.1.8 source snapshot
Includes the current working directory changes. Historical commits and release binaries are excluded to limit Gitea storage.
Source base: d7515973fbfd6dd2001fa156c483455b8081c7cb
2026-11-10 01:24:28 +08:00

137 lines
4.3 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package client
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
"cursor/internal/modelchannel"
)
// 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)
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 := 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
}