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
This commit is contained in:
FuDie0915
2026-11-10 01:24:28 +08:00
commit db73cf5a39
337 changed files with 147502 additions and 0 deletions

88
internal/client/config.go Normal file
View File

@@ -0,0 +1,88 @@
package client
import (
"context"
"cursor/internal/appdata"
serverconfig "cursor/internal/backend/server/config"
"github.com/wailsapp/wails/v3/pkg/application"
)
// UserConfig 定义了当前模块中的 UserConfig 类型。
type UserConfig = serverconfig.Config
// LoadUserConfig 用于处理与 LoadUserConfig 相关的逻辑。
func (s *ProxyService) LoadUserConfig() (UserConfig, error) {
if s == nil {
return serverconfig.DefaultConfig(), nil
}
app := application.Get()
ctx := context.Background()
if app != nil {
ctx = app.Context()
}
if s.backendHost != nil {
return s.backendHost.LoadConfig(ctx)
}
if s.store == nil {
return serverconfig.DefaultConfig(), nil
}
return s.store.Load(ctx)
}
// SaveUserConfig 用于处理与 SaveUserConfig 相关的逻辑。
func (s *ProxyService) SaveUserConfig(cfg UserConfig) error {
if s == nil {
return nil
}
app := application.Get()
ctx := context.Background()
if app != nil {
ctx = app.Context()
}
var (
normalized UserConfig
err error
)
if s.backendHost != nil {
normalized, err = s.backendHost.SaveConfig(ctx, cfg)
} else if s.store != nil {
normalized, err = s.store.Save(ctx, cfg)
} else {
return nil
}
if err != nil {
return err
}
s.emitUserConfigChanged(normalized)
return nil
}
func (s *ProxyService) emitUserConfigChanged(cfg UserConfig) {
app := application.Get()
if app == nil {
return
}
app.Event.Emit("user-config:changed", cfg)
}
// resolveUserConfigPath 用于处理与 resolveUserConfigPath 相关的逻辑。
func resolveUserConfigPath() string {
return appdata.ConfigFilePath()
}
// resolveLogsRootPath 用于处理与 resolveLogsRootPath 相关的逻辑。
func resolveLogsRootPath() string {
return appdata.LogsRootPath()
}
// ResolveLogsRootPath 用于处理与 ResolveLogsRootPath 相关的逻辑。
func ResolveLogsRootPath() string {
return resolveLogsRootPath()
}
// ResolveSettingsRootPath 用于处理与 ResolveSettingsRootPath 相关的逻辑。
func ResolveSettingsRootPath() string {
return appdata.RootDir()
}

111
internal/client/cursor.go Normal file
View File

@@ -0,0 +1,111 @@
package client
import (
"fmt"
goruntime "runtime"
"cursor/internal/cursor"
"cursor/internal/logger"
)
// ApplyCursorSettings 用于处理与 ApplyCursorSettings 相关的逻辑。
func (s *ProxyService) ApplyCursorSettings() error {
if s == nil || s.proxy == nil {
return fmt.Errorf("proxy is not initialized")
}
s.caFileMu.Lock()
caCertPath, err := cursor.EnsureCACertFile(s.caCertPEM, s.caFilePath)
if err == nil {
s.caFilePath = caCertPath
}
s.caFileMu.Unlock()
if err != nil {
return fmt.Errorf("ensure ca cert file: %w", err)
}
switch goruntime.GOOS {
case "windows":
if err := cursor.EnsureCACertInstalled(s.caCertPEM, caCertPath); err != nil {
return fmt.Errorf("install ca cert: %w", err)
}
case "darwin":
if err := cursor.EnsureCACertInstalled(s.caCertPEM, caCertPath); err != nil {
return fmt.Errorf("install ca cert: %w", err)
}
if err := cursor.SetSystemNodeExtraCACerts(caCertPath); err != nil {
return fmt.Errorf("set node extra ca certs: %w", err)
}
}
proxyURL := cursor.ProxyURLFromListenAddr(s.proxy.Snapshot().ListenAddr)
if err := cursor.WriteUserProxySettings(proxyURL); err != nil {
return err
}
// CLI 版 wrapper: 不再需要 HTTP_PROXY,由 preload 脚本直接重定向 tls.connect 到后端 HTTPS 端口
// 注入 NODE_EXTRA_CA_CERTS + CURSOR_AUTH_TOKEN
// 如果未检测到 CLI 安装,跳过但不报错
if cliPath := cursor.DetectCLIInstallPath(); cliPath != "" {
cliHTTPSAddr := ""
if s.backendHost != nil {
cliHTTPSAddr = s.backendHost.CLIHTTPSListenAddr()
}
if err := cursor.WriteCLIProxyWrapper(cliHTTPSAddr, caCertPath); err != nil {
logger.Errorf("write cli proxy wrapper failed: %v", err)
}
// 写入 mock auth.json,使 CLI 跳过交互式登录
if err := cursor.WriteCLIAuthFile(); err != nil {
logger.Errorf("write cli auth.json failed: %v", err)
}
} else {
logger.Infof("applyCursorSettings: cli not detected, skipping wrapper")
}
s.setCursorSettingsApplied(true)
return nil
}
// ClearCursorSettings 用于处理与 ClearCursorSettings 相关的逻辑。
func (s *ProxyService) ClearCursorSettings() error {
if goruntime.GOOS == "darwin" {
if err := cursor.ClearSystemNodeExtraCACerts(); err != nil {
return err
}
}
if err := cursor.ClearUserProxySettings(); err != nil {
return err
}
if err := cursor.ClearCLIProxyWrapper(); err != nil {
logger.Errorf("clear cli proxy wrapper failed: %v", err)
}
if err := cursor.ClearCLIAuthFile(); err != nil {
logger.Errorf("clear cli auth.json failed: %v", err)
}
s.setCursorSettingsApplied(false)
return nil
}
// GetDeviceID 用于处理与 GetDeviceID 相关的逻辑。
func (s *ProxyService) GetDeviceID() (string, error) {
return cursor.GetDeviceID()
}
// CLIStatus 返回 CLI 版 cursor-agent 的代理状态。
type CLIStatus struct {
Installed bool `json:"installed"`
InstallPath string `json:"installPath"`
WrapperActive bool `json:"wrapperActive"`
WrapperPath string `json:"wrapperPath"`
}
// GetCLIStatus 用于处理与 GetCLIStatus 相关的逻辑。
func (s *ProxyService) GetCLIStatus() CLIStatus {
path := cursor.DetectCLIInstallPath()
return CLIStatus{
Installed: path != "",
InstallPath: path,
WrapperActive: cursor.CLIWrapperExists(),
WrapperPath: cursor.CLIWrapperPath(),
}
}

2
internal/client/doc.go Normal file
View File

@@ -0,0 +1,2 @@
// Package clientruntime 负责协调本地客户端运行时、配置、状态与外部适配逻辑。
package client

145
internal/client/license.go Normal file
View File

@@ -0,0 +1,145 @@
package client
import (
"encoding/json"
"errors"
)
// LicenseActionRequest 定义了当前模块中的 LicenseActionRequest 类型。
type LicenseActionRequest struct {
// Host 表示当前声明中的 Host。
Host string `json:"host"`
// Code 表示当前声明中的 Code。
Code string `json:"code"`
// DeviceID 表示当前声明中的 DeviceID。
DeviceID string `json:"deviceId"`
// DeviceMeta 表示当前声明中的 DeviceMeta。
DeviceMeta string `json:"deviceMeta"`
}
// LicenseSwitchDeviceRequest 定义了当前模块中的 LicenseSwitchDeviceRequest 类型。
type LicenseSwitchDeviceRequest struct {
// Host 表示当前声明中的 Host。
Host string `json:"host"`
// Code 表示当前声明中的 Code。
Code string `json:"code"`
// FromDeviceID 表示当前声明中的 FromDeviceID。
FromDeviceID string `json:"fromDeviceId"`
// ToDeviceID 表示当前声明中的 ToDeviceID。
ToDeviceID string `json:"toDeviceId"`
// Remark 表示当前声明中的 Remark。
Remark string `json:"remark"`
}
// LicenseAPIResult 定义了当前模块中的 LicenseAPIResult 类型。
type LicenseAPIResult struct {
// Code 表示当前声明中的 Code。
Code string `json:"code"`
// Message 表示当前声明中的 Message。
Message string `json:"message"`
// Data 表示当前声明中的 Data。
Data map[string]any `json:"data,omitempty"`
}
// UsageRecordsRequest 定义了当前模块中的 UsageRecordsRequest 类型。
type UsageRecordsRequest struct {
// Host 表示当前声明中的 Host。
Host string `json:"host"`
// Code 表示当前声明中的 Code。
Code string `json:"code"`
// Page 表示当前声明中的 Page。
Page int `json:"page"`
// PageSize 表示当前声明中的 PageSize。
PageSize int `json:"pageSize"`
// StartTime 表示当前声明中的 StartTime。
StartTime string `json:"startTime"`
// EndTime 表示当前声明中的 EndTime。
EndTime string `json:"endTime"`
// RequestID 表示当前声明中的 RequestID。
RequestID string `json:"requestId"`
// ConversationID 表示当前声明中的 ConversationID。
ConversationID string `json:"conversationId"`
// RuntimeModelID 表示当前声明中的 RuntimeModelID。
RuntimeModelID string `json:"runtimeModelId"`
}
// UsageRecord 定义了当前模块中的 UsageRecord 类型。
type UsageRecord struct {
// CreatedAt 表示当前声明中的 CreatedAt。
CreatedAt string `json:"createdAt"`
// RuntimeModelID 表示当前声明中的 RuntimeModelID。
RuntimeModelID string `json:"runtimeModelId"`
// RequestID 表示当前声明中的 RequestID。
RequestID string `json:"requestId"`
// ConversationID 表示当前声明中的 ConversationID。
ConversationID string `json:"conversationId"`
}
// UsageRecordsData 定义了当前模块中的 UsageRecordsData 类型。
type UsageRecordsData struct {
// Items 表示当前声明中的 Items。
Items []UsageRecord `json:"items"`
// Total 表示当前声明中的 Total。
Total int `json:"total"`
// Page 表示当前声明中的 Page。
Page int `json:"page"`
// PageSize 表示当前声明中的 PageSize。
PageSize int `json:"pageSize"`
}
// UsageRecordsResult 定义了当前模块中的 UsageRecordsResult 类型。
type UsageRecordsResult struct {
// Code 表示当前声明中的 Code。
Code string `json:"code"`
// Message 表示当前声明中的 Message。
Message string `json:"message"`
// Data 表示当前声明中的 Data。
Data UsageRecordsData `json:"data"`
}
// ActivateLicense 用于处理与 ActivateLicense 相关的逻辑。
func (s *ProxyService) ActivateLicense(LicenseActionRequest) (LicenseAPIResult, error) {
return LicenseAPIResult{}, errors.New("activation has been removed from the local client")
}
// BindLicenseDevice 用于处理与 BindLicenseDevice 相关的逻辑。
func (s *ProxyService) BindLicenseDevice(LicenseActionRequest) (LicenseAPIResult, error) {
return LicenseAPIResult{}, errors.New("device binding has been removed from the local client")
}
// SwitchLicenseDevice 用于处理与 SwitchLicenseDevice 相关的逻辑。
func (s *ProxyService) SwitchLicenseDevice(LicenseSwitchDeviceRequest) (LicenseAPIResult, error) {
return LicenseAPIResult{}, errors.New("device switching has been removed from the local client")
}
// QueryUsageRecords 用于处理与 QueryUsageRecords 相关的逻辑。
func (s *ProxyService) QueryUsageRecords(req UsageRecordsRequest) (UsageRecordsResult, error) {
page := req.Page
if page <= 0 {
page = 1
}
pageSize := req.PageSize
if pageSize <= 0 {
pageSize = 20
}
return UsageRecordsResult{
Code: "UNSUPPORTED",
Message: "usage records UI has been removed from the local client",
Data: UsageRecordsData{
Items: []UsageRecord{},
Total: 0,
Page: page,
PageSize: pageSize,
},
}, nil
}
// MarshalJSON 用于处理与 MarshalJSON 相关的逻辑。
func (result LicenseAPIResult) MarshalJSON() ([]byte, error) {
type alias LicenseAPIResult
output := alias(result)
if output.Data == nil {
output.Data = map[string]any{}
}
return json.Marshal(output)
}

View File

@@ -0,0 +1,292 @@
package client
import (
"context"
"errors"
"fmt"
"strings"
"time"
"cursor/internal/cursor"
"cursor/internal/logger"
"cursor/internal/mitm"
"cursor/internal/netproxy"
localruntime "cursor/internal/runtime"
"github.com/wailsapp/wails/v3/pkg/application"
)
// ProxyState 定义了当前模块中的 ProxyState 类型。
type ProxyState struct {
// ListenAddr 保留旧字段兼容前端缓存,实际值等于 proxyListenAddr。
ListenAddr string `json:"listenAddr"`
// Running 保留旧字段兼容前端缓存,实际值等于 proxyRunning。
Running bool `json:"running"`
// BackendListenAddr 表示嵌入式 backend 监听地址。
BackendListenAddr string `json:"backendListenAddr"`
// BackendRunning 表示嵌入式 backend 是否已启动。
BackendRunning bool `json:"backendRunning"`
// ProxyListenAddr 表示 MITM 代理监听地址。
ProxyListenAddr string `json:"proxyListenAddr"`
// ProxyRunning 表示 MITM 代理是否已启动。
ProxyRunning bool `json:"proxyRunning"`
// CLIHTTPSListenAddr 表示 CLI 专用 HTTPS+HTTP/2 监听地址。
CLIHTTPSListenAddr string `json:"cliHttpsListenAddr"`
// CLIHTTPSRunning 表示 CLI 专用 HTTPS 监听器是否已启动。
CLIHTTPSRunning bool `json:"cliHttpsRunning"`
// CursorSettingsApplied 表示宿主代理设置是否已注入。
CursorSettingsApplied bool `json:"cursorSettingsApplied"`
// NetProxySource 表示当前出站网络代理来源:system/env/direct。
NetProxySource string `json:"netProxySource"`
// NetProxyActive 表示当前出站网络代理是否启用。
NetProxyActive bool `json:"netProxyActive"`
// NetProxyUsingSystem 表示当前出站网络代理是否来自操作系统代理。
NetProxyUsingSystem bool `json:"netProxyUsingSystem"`
// NetProxyUsingEnv 表示当前出站网络代理是否来自环境变量。
NetProxyUsingEnv bool `json:"netProxyUsingEnv"`
// NetProxyHTTP 表示当前 HTTP 代理地址,已移除凭据。
NetProxyHTTP string `json:"netProxyHttp"`
// NetProxyHTTPS 表示当前 HTTPS 代理地址,已移除凭据。
NetProxyHTTPS string `json:"netProxyHttps"`
// NetProxyPACIgnored 表示检测到 PAC/自动代理但本轮按直连处理。
NetProxyPACIgnored bool `json:"netProxyPacIgnored"`
// NetProxyDescription 表示当前出站网络代理摘要,已移除凭据。
NetProxyDescription string `json:"netProxyDescription"`
// LastError 表示当前声明中的 LastError。
LastError string `json:"lastError"`
}
// StartProxy 用于处理与 StartProxy 相关的逻辑。
func (s *ProxyService) StartProxy() (ProxyState, error) {
logger.Infof("start service requested config_path=%s logs_root=%s", s.configPath, s.logsRoot)
fail := func(step string, err error) (ProxyState, error) {
logger.Errorf("start service failed step=%s err=%v", step, err)
s.setLastError(err)
s.emitState()
return s.GetState(), err
}
cfg, err := s.LoadUserConfig()
if err != nil {
return fail("load_user_config", err)
}
if err := s.ensureBackendHost(); err != nil {
return fail("ensure_backend_host", err)
}
if !s.backendHost.IsRunning() {
logger.Infof("starting embedded backend listen_addr=%s", s.backendHost.ListenAddr())
if err := s.backendHost.Start(); err != nil {
return fail("start_backend", err)
}
} else {
logger.Infof("embedded backend already running listen_addr=%s", s.backendHost.ListenAddr())
}
healthCtx, healthCancel := context.WithTimeout(context.Background(), backendReadyTimeout)
defer healthCancel()
if err := s.waitForBackend(healthCtx); err != nil {
return fail("wait_backend_ready", err)
}
logger.Infof("embedded backend ready listen_addr=%s", s.backendHost.ListenAddr())
if err := s.ensureProxy(cfg); err != nil {
return fail("ensure_proxy", err)
}
// 启动时注入账号信息
if err := cursor.InjectCursorUserInfo(localruntime.InjectAccountEmail, localruntime.InjectAuthToken); err != nil {
logger.Errorf("injectCursorUserInfo failed: %v", err)
// 不阻断启动,仅记录日志
}
if s.proxy != nil && !s.proxy.IsRunning() {
logger.Infof("starting mitm proxy listen_addr=%s", s.proxy.Snapshot().ListenAddr)
if err := s.proxy.Start(); err != nil {
return fail("start_mitm_proxy", err)
}
}
if err := s.ApplyCursorSettings(); err != nil {
stopCtx, stopCancel := context.WithTimeout(context.Background(), 5*time.Second)
defer stopCancel()
if s.proxy != nil {
_ = s.proxy.Stop(stopCtx)
}
_ = s.backendHost.Stop(stopCtx)
startErr := fmt.Errorf("服务已启动,但注入 Cursor 配置失败: %w", err)
logger.Errorf("start service failed step=apply_cursor_settings err=%v", startErr)
s.setLastError(startErr)
s.emitState()
return s.GetState(), startErr
}
s.setLastError(nil)
s.emitState()
state := s.GetState()
logger.Infof(
"start service completed backend_listen_addr=%s proxy_listen_addr=%s cursor_settings_applied=%t",
state.BackendListenAddr,
state.ProxyListenAddr,
state.CursorSettingsApplied,
)
return state, nil
}
// StopProxy 用于处理与 StopProxy 相关的逻辑。
// 各清理步骤失败时记录错误但继续执行后续步骤,保证 backend 最终被停止,
// 避免"Cursor 配置已清理但 backend 仍在运行"的不一致状态。
func (s *ProxyService) StopProxy() (ProxyState, error) {
logger.Infof("stop service requested")
var stopErrors []error
fail := func(step string, err error) {
if err == nil || errors.Is(err, context.Canceled) {
return
}
logger.Errorf("stop service failed step=%s err=%v", step, err)
stopErrors = append(stopErrors, fmt.Errorf("%s: %w", step, err))
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if s.proxy != nil && s.proxy.IsRunning() {
logger.Infof("stopping mitm proxy listen_addr=%s", s.proxy.Snapshot().ListenAddr)
fail("stop_mitm_proxy", s.proxy.Stop(ctx))
}
fail("clear_cursor_settings", s.ClearCursorSettings())
if s.backendHost != nil {
logger.Infof("stopping embedded backend listen_addr=%s", s.backendHost.ListenAddr())
fail("stop_backend", s.backendHost.Stop(ctx))
}
if len(stopErrors) > 0 {
stopErr := errors.Join(stopErrors...)
s.setLastError(stopErr)
s.emitState()
return s.GetState(), stopErr
}
s.setLastError(nil)
s.emitState()
state := s.GetState()
logger.Infof(
"stop service completed backend_running=%t proxy_running=%t cursor_settings_applied=%t",
state.BackendRunning,
state.ProxyRunning,
state.CursorSettingsApplied,
)
return state, nil
}
// GetState 用于处理与 GetState 相关的逻辑。
func (s *ProxyService) GetState() ProxyState {
var proxySnap mitm.Snapshot
if s.proxy != nil {
proxySnap = s.proxy.Snapshot()
}
s.mu.RLock()
lastError := s.lastError
cursorSettingsApplied := s.cursorSettingsApplied
s.mu.RUnlock()
backendListenAddr := ""
backendRunning := false
cliHTTPSListenAddr := ""
cliHTTPSRunning := false
if s.backendHost != nil {
backendListenAddr = s.backendHost.ListenAddr()
backendRunning = s.backendHost.IsRunning()
cliHTTPSListenAddr = s.backendHost.CLIHTTPSListenAddr()
cliHTTPSRunning = s.backendHost.CLIHTTPSRunning()
}
netProxy := netproxy.CurrentStatus()
return ProxyState{
ListenAddr: proxySnap.ListenAddr,
Running: proxySnap.Running,
BackendListenAddr: backendListenAddr,
BackendRunning: backendRunning,
ProxyListenAddr: proxySnap.ListenAddr,
ProxyRunning: proxySnap.Running,
CLIHTTPSListenAddr: cliHTTPSListenAddr,
CLIHTTPSRunning: cliHTTPSRunning,
CursorSettingsApplied: cursorSettingsApplied,
NetProxySource: netProxy.Source,
NetProxyActive: netProxy.Active,
NetProxyUsingSystem: netProxy.UsingSystemProxy,
NetProxyUsingEnv: netProxy.UsingEnvProxy,
NetProxyHTTP: netProxy.HTTPProxy,
NetProxyHTTPS: netProxy.HTTPSProxy,
NetProxyPACIgnored: netProxy.PACIgnored,
NetProxyDescription: netProxy.Description,
LastError: lastError,
}
}
// ClearLastError 用于处理与 ClearLastError 相关的逻辑。
func (s *ProxyService) ClearLastError() ProxyState {
s.setLastError(nil)
s.emitState()
return s.GetState()
}
// SetBaseURL 用于处理与 SetBaseURL 相关的逻辑。
func (s *ProxyService) SetBaseURL(baseURL string) (ProxyState, error) {
_ = strings.TrimSpace(baseURL)
err := fmt.Errorf("backend/proxy 地址已固定,不再支持直接修改 baseURL")
s.setLastError(err)
s.emitState()
return s.GetState(), err
}
// setLastError 用于处理与 setLastError 相关的逻辑。
func (s *ProxyService) setLastError(err error) {
s.mu.Lock()
defer s.mu.Unlock()
if err == nil {
s.lastError = ""
return
}
msg := strings.TrimSpace(err.Error())
if msg == "" {
msg = "unknown error"
}
s.lastError = msg
}
// emitState 用于处理与 emitState 相关的逻辑。
func (s *ProxyService) emitState() {
app := application.Get()
if app == nil {
return
}
state := s.GetState()
if state.Running {
state.LastError = ""
}
app.Event.Emit("proxy:state", state)
}
// ShutdownForQuit 用于处理与 ShutdownForQuit 相关的逻辑。
func (s *ProxyService) ShutdownForQuit() {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
var finalErr error
if s.proxy != nil {
if err := s.proxy.Stop(ctx); err != nil && !errors.Is(err, context.Canceled) {
finalErr = err
}
}
if err := s.ClearCursorSettings(); err != nil {
finalErr = errors.Join(finalErr, err)
}
if s.backendHost != nil {
if err := s.backendHost.Stop(ctx); err != nil && !errors.Is(err, context.Canceled) {
finalErr = errors.Join(finalErr, err)
}
}
if finalErr != nil {
s.setLastError(finalErr)
}
}
func (s *ProxyService) setCursorSettingsApplied(applied bool) {
s.mu.Lock()
defer s.mu.Unlock()
s.cursorSettingsApplied = applied
}

View File

@@ -0,0 +1,771 @@
package client
import (
"context"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"hash/fnv"
"io"
"math"
"net/http"
"sort"
"strconv"
"strings"
"sync"
"time"
"unicode/utf8"
modeladapter "cursor/internal/backend/agent/model"
serverconfig "cursor/internal/backend/server/config"
"cursor/internal/modelchannel"
"github.com/wailsapp/wails/v3/pkg/application"
)
const (
modelAdapterTestUpdatedEvent = "model-adapter-test:updated"
modelAdapterTestPrompt = "Output the numbers 1 through 120 separated by a single space. No commas, no newlines, no explanation."
modelAdapterTestTimeout = 45 * time.Second
modelAdapterTestDefaultMaxTokens = 65_536
modelAdapterTestEmptyTextError = "未收到文本输出,无法计算测速结果"
modelAdapterTestMaxErrorBodyBytes = 8192
)
type ModelAdapterTestStatus string
const (
ModelAdapterTestStatusIdle ModelAdapterTestStatus = "idle"
ModelAdapterTestStatusRunning ModelAdapterTestStatus = "running"
ModelAdapterTestStatusSuccess ModelAdapterTestStatus = "success"
ModelAdapterTestStatusError ModelAdapterTestStatus = "error"
)
// ModelAdapterTestResult 表示一次模型测速结果。
type ModelAdapterTestResult struct {
AdapterID string `json:"adapterID"`
RequestHash string `json:"requestHash"`
Status string `json:"status"`
TokensPerSecond float64 `json:"tokensPerSecond"`
FirstTextTokenMS int64 `json:"firstTextTokenMS"`
TotalDurationMS int64 `json:"totalDurationMS"`
OutputTokens int64 `json:"outputTokens"`
TokensEstimated bool `json:"tokensEstimated"`
SummaryText string `json:"summaryText"`
Error string `json:"error"`
RawResponse string `json:"rawResponse"`
TestedAt string `json:"testedAt"`
}
// ModelAdapterTestResultsPayload 用于向前端广播当前测速结果快照。
type ModelAdapterTestResultsPayload struct {
Results []ModelAdapterTestResult `json:"results"`
}
type modelAdapterTestMetrics struct {
firstTextTokenAt time.Time
finishedAt time.Time
outputTokens int64
outputProvided bool
text strings.Builder
rawResponse string
}
type modelAdapterTestArtifactObserver struct {
mu sync.Mutex
response strings.Builder
}
func (observer *modelAdapterTestArtifactObserver) RecordLLMRequest(string, string, string, map[string]any) (string, error) {
return "", nil
}
func (observer *modelAdapterTestArtifactObserver) AppendLLMResponseChunk(_ string, _ string, _ string, chunk string) (string, error) {
if observer == nil {
return "", nil
}
observer.mu.Lock()
defer observer.mu.Unlock()
_, _ = observer.response.WriteString(chunk)
return "", nil
}
func (observer *modelAdapterTestArtifactObserver) RecordLLMSummary(string, string, string, map[string]any) (string, error) {
return "", nil
}
func (observer *modelAdapterTestArtifactObserver) RawResponse() string {
if observer == nil {
return ""
}
observer.mu.Lock()
defer observer.mu.Unlock()
return strings.TrimSpace(observer.response.String())
}
func (s *ProxyService) GetModelAdapterTestResults() []ModelAdapterTestResult {
return s.snapshotModelAdapterTestResults()
}
func (s *ProxyService) TestModelAdapter(adapter serverconfig.ModelAdapterConfig) (ModelAdapterTestResult, error) {
requestHash := buildModelAdapterTestRequestHash(adapter)
adapterID := buildModelAdapterTestCacheKey(adapter, requestHash)
if cached, ok := s.getRunningModelAdapterTestResult(adapterID, requestHash); ok {
return cached, nil
}
normalized, err := normalizeSingleModelAdapterConfig(adapter)
if err != nil {
result := ModelAdapterTestResult{
AdapterID: adapterID,
RequestHash: requestHash,
Status: string(ModelAdapterTestStatusError),
SummaryText: buildModelAdapterTestErrorSummary(err),
Error: buildModelAdapterTestErrorSummary(err),
RawResponse: strings.TrimSpace(modelAdapterTestErrorMessage(err)),
TestedAt: time.Now().UTC().Format(time.RFC3339Nano),
}
s.storeAndEmitModelAdapterTestResult(result)
return result, err
}
running := ModelAdapterTestResult{
AdapterID: normalized.ID,
RequestHash: requestHash,
Status: string(ModelAdapterTestStatusRunning),
SummaryText: "测试中...",
TestedAt: time.Now().UTC().Format(time.RFC3339Nano),
}
s.storeAndEmitModelAdapterTestResult(running)
result, testErr := s.runModelAdapterTest(normalized, requestHash)
s.storeAndEmitModelAdapterTestResult(result)
if testErr != nil {
return result, testErr
}
return result, nil
}
func normalizeSingleModelAdapterConfig(adapter serverconfig.ModelAdapterConfig) (serverconfig.ModelAdapterConfig, error) {
normalized, err := serverconfig.NormalizeModelAdapterConfigs([]serverconfig.ModelAdapterConfig{adapter})
if err != nil {
return serverconfig.ModelAdapterConfig{}, err
}
if len(normalized) == 0 {
return serverconfig.ModelAdapterConfig{}, errors.New("模型配置不能为空")
}
return normalized[0], nil
}
func (s *ProxyService) runModelAdapterTest(adapter serverconfig.ModelAdapterConfig, requestHash string) (ModelAdapterTestResult, error) {
ctx, cancel := context.WithTimeout(context.Background(), modelAdapterTestTimeout)
defer cancel()
startedAt := time.Now().UTC()
metrics, requestErr := s.executeModelAdapterNonStreamingTest(ctx, adapter)
if requestErr != nil {
result := buildErroredModelAdapterTestResult(adapter.ID, requestHash, requestErr)
return result, requestErr
}
if metrics.finishedAt.IsZero() {
metrics.finishedAt = time.Now().UTC()
}
if metrics.firstTextTokenAt.IsZero() {
emptyTextErr := errors.New(modelAdapterTestEmptyTextError)
result := buildErroredModelAdapterTestResult(adapter.ID, requestHash, emptyTextErr)
return result, emptyTextErr
}
outputTokens := metrics.outputTokens
tokensEstimated := false
if !metrics.outputProvided || outputTokens <= 0 {
outputTokens = estimateBenchmarkTextTokens(metrics.text.String())
tokensEstimated = true
}
firstTextTokenMS := metrics.firstTextTokenAt.Sub(startedAt).Milliseconds()
if firstTextTokenMS < 0 {
firstTextTokenMS = 0
}
totalDurationMS := metrics.finishedAt.Sub(startedAt).Milliseconds()
if totalDurationMS < 0 {
totalDurationMS = 0
}
tokensPerSecond := 0.0
totalDuration := metrics.finishedAt.Sub(startedAt)
if outputTokens > 0 && totalDuration > 0 {
tokensPerSecond = float64(outputTokens) / totalDuration.Seconds()
}
result := ModelAdapterTestResult{
AdapterID: adapter.ID,
RequestHash: requestHash,
Status: string(ModelAdapterTestStatusSuccess),
TokensPerSecond: tokensPerSecond,
FirstTextTokenMS: firstTextTokenMS,
TotalDurationMS: totalDurationMS,
OutputTokens: outputTokens,
TokensEstimated: tokensEstimated,
TestedAt: time.Now().UTC().Format(time.RFC3339Nano),
RawResponse: strings.TrimSpace(metrics.rawResponse),
}
result.SummaryText = buildModelAdapterTestSummaryText(result)
return result, nil
}
func (s *ProxyService) executeModelAdapterNonStreamingTest(ctx context.Context, adapter serverconfig.ModelAdapterConfig) (*modelAdapterTestMetrics, error) {
switch strings.TrimSpace(adapter.Type) {
case "openai":
return s.executeOpenAIStreamingTest(ctx, adapter)
case "anthropic":
return s.executeAnthropicStreamingTest(ctx, adapter)
default:
return nil, fmt.Errorf("unsupported provider %q", strings.TrimSpace(adapter.Type))
}
}
func (s *ProxyService) executeOpenAIStreamingTest(ctx context.Context, adapter serverconfig.ModelAdapterConfig) (*modelAdapterTestMetrics, error) {
_ = s
metrics := &modelAdapterTestMetrics{}
observer := &modelAdapterTestArtifactObserver{}
maxTokens := modelAdapterTestConfiguredOpenAIMaxTokens(adapter)
requestID := "model-adapter-test-" + buildModelAdapterTestRequestHash(adapter)
req := modeladapter.StreamRequest{
RequestID: requestID,
RunID: requestID,
ModelCallID: requestID,
ModelID: strings.TrimSpace(adapter.ID),
Provider: "openai",
BaseURL: strings.TrimSpace(adapter.BaseURL),
APIKey: strings.TrimSpace(adapter.APIKey),
ProviderModelID: strings.TrimSpace(adapter.ModelID),
ResolvedChannelID: strings.TrimSpace(adapter.ID),
ResolvedChannelName: strings.TrimSpace(adapter.DisplayName),
ResolvedContextWindowTokens: adapter.ContextWindowTokens,
ReasoningEffort: strings.TrimSpace(adapter.ReasoningEffort),
OpenAIEndpoint: strings.TrimSpace(adapter.OpenAIEndpoint),
OpenAIExtraParamsEnabled: adapter.OpenAIExtraParamsEnabled,
OpenAIExtraParamsJSON: strings.TrimSpace(adapter.OpenAIExtraParamsJSON),
CustomHeadersEnabled: adapter.CustomHeadersEnabled,
CustomHeadersJSON: strings.TrimSpace(adapter.CustomHeadersJSON),
Messages: []modeladapter.Message{{Role: "user", Content: modelAdapterTestPrompt}},
MaxTokens: maxTokens,
Stream: true,
RequestKnobs: map[string]any{"stream": true, "max_tokens": maxTokens},
Observer: observer,
ProviderStreamIdleTimeout: modelAdapterTestTimeout,
}
err := modeladapter.NewOpenAIAdapter().Stream(ctx, req, func(event modeladapter.ModelEvent) error {
now := time.Now().UTC()
switch event.Kind {
case modeladapter.ModelEventKindTextDelta:
if strings.TrimSpace(event.Text) != "" && metrics.firstTextTokenAt.IsZero() {
metrics.firstTextTokenAt = now
}
_, _ = metrics.text.WriteString(event.Text)
case modeladapter.ModelEventKindTurnFinished:
metrics.finishedAt = now
if event.OutputTokens > 0 {
metrics.outputTokens = event.OutputTokens
metrics.outputProvided = true
}
case modeladapter.ModelEventKindProviderError:
if event.Err != nil {
return event.Err
}
return errors.New("provider error")
}
return nil
})
if err != nil {
return nil, err
}
if metrics.finishedAt.IsZero() {
metrics.finishedAt = time.Now().UTC()
}
metrics.rawResponse = observer.RawResponse()
if strings.TrimSpace(metrics.rawResponse) == "" {
metrics.rawResponse = strings.TrimSpace(metrics.text.String())
}
return metrics, nil
}
func (s *ProxyService) executeAnthropicStreamingTest(ctx context.Context, adapter serverconfig.ModelAdapterConfig) (*modelAdapterTestMetrics, error) {
_ = s
metrics := &modelAdapterTestMetrics{}
observer := &modelAdapterTestArtifactObserver{}
maxTokens := modelAdapterTestConfiguredAnthropicMaxTokens(adapter)
thinkingEffort := normalizeModelAdapterTestAnthropicThinkingEffort(adapter.AnthropicThinkingEffort)
requestID := "model-adapter-test-" + buildModelAdapterTestRequestHash(adapter)
req := modeladapter.StreamRequest{
RequestID: requestID,
RunID: requestID,
ModelCallID: requestID,
ModelID: strings.TrimSpace(adapter.ID),
Provider: "anthropic",
BaseURL: strings.TrimSpace(adapter.BaseURL),
APIKey: strings.TrimSpace(adapter.APIKey),
ProviderModelID: strings.TrimSpace(adapter.ModelID),
ResolvedChannelID: strings.TrimSpace(adapter.ID),
ResolvedChannelName: strings.TrimSpace(adapter.DisplayName),
ResolvedContextWindowTokens: adapter.ContextWindowTokens,
ThinkingEffort: thinkingEffort,
AnthropicMaxTokens: maxTokens,
AnthropicThinkingEffort: thinkingEffort,
CustomHeadersEnabled: adapter.CustomHeadersEnabled,
CustomHeadersJSON: strings.TrimSpace(adapter.CustomHeadersJSON),
AnthropicExtraParamsEnabled: adapter.AnthropicExtraParamsEnabled,
AnthropicExtraParamsJSON: strings.TrimSpace(adapter.AnthropicExtraParamsJSON),
ThinkingBudgetTokens: adapter.ThinkingBudgetTokens,
Messages: []modeladapter.Message{{Role: "user", Content: modelAdapterTestPrompt}},
MaxTokens: maxTokens,
Stream: true,
RequestKnobs: map[string]any{"stream": true, "anthropic_max_tokens": maxTokens, "max_tokens": maxTokens},
Observer: observer,
ProviderStreamIdleTimeout: modelAdapterTestTimeout,
}
err := modeladapter.NewAnthropicAdapter().Stream(ctx, req, func(event modeladapter.ModelEvent) error {
now := time.Now().UTC()
switch event.Kind {
case modeladapter.ModelEventKindTextDelta:
if strings.TrimSpace(event.Text) != "" && metrics.firstTextTokenAt.IsZero() {
metrics.firstTextTokenAt = now
}
_, _ = metrics.text.WriteString(event.Text)
case modeladapter.ModelEventKindTurnFinished:
metrics.finishedAt = now
if event.OutputTokens > 0 {
metrics.outputTokens = event.OutputTokens
metrics.outputProvided = true
}
case modeladapter.ModelEventKindProviderError:
if event.Err != nil {
return event.Err
}
return errors.New("provider error")
}
return nil
})
if err != nil {
return nil, err
}
if metrics.finishedAt.IsZero() {
metrics.finishedAt = time.Now().UTC()
}
metrics.rawResponse = observer.RawResponse()
if strings.TrimSpace(metrics.rawResponse) == "" {
metrics.rawResponse = strings.TrimSpace(metrics.text.String())
}
return metrics, nil
}
func (s *ProxyService) getRunningModelAdapterTestResult(adapterID string, requestHash string) (ModelAdapterTestResult, bool) {
s.modelTestMu.RLock()
defer s.modelTestMu.RUnlock()
if s.modelTestResults == nil {
return ModelAdapterTestResult{}, false
}
result, ok := s.modelTestResults[adapterID]
if !ok {
return ModelAdapterTestResult{}, false
}
if strings.TrimSpace(result.Status) != string(ModelAdapterTestStatusRunning) {
return ModelAdapterTestResult{}, false
}
if strings.TrimSpace(result.RequestHash) != strings.TrimSpace(requestHash) {
return ModelAdapterTestResult{}, false
}
return result, true
}
func (s *ProxyService) storeAndEmitModelAdapterTestResult(result ModelAdapterTestResult) {
if strings.TrimSpace(result.AdapterID) == "" {
return
}
s.modelTestMu.Lock()
if s.modelTestResults == nil {
s.modelTestResults = make(map[string]ModelAdapterTestResult)
}
s.modelTestResults[result.AdapterID] = result
snapshot := snapshotModelAdapterTestResultsLocked(s.modelTestResults)
s.modelTestMu.Unlock()
s.emitModelAdapterTestResults(snapshot)
}
func (s *ProxyService) snapshotModelAdapterTestResults() []ModelAdapterTestResult {
s.modelTestMu.RLock()
defer s.modelTestMu.RUnlock()
return snapshotModelAdapterTestResultsLocked(s.modelTestResults)
}
func snapshotModelAdapterTestResultsLocked(items map[string]ModelAdapterTestResult) []ModelAdapterTestResult {
if len(items) == 0 {
return []ModelAdapterTestResult{}
}
results := make([]ModelAdapterTestResult, 0, len(items))
for _, item := range items {
results = append(results, item)
}
sort.Slice(results, func(i int, j int) bool {
if results[i].TestedAt == results[j].TestedAt {
return results[i].AdapterID < results[j].AdapterID
}
return results[i].TestedAt > results[j].TestedAt
})
return results
}
func (s *ProxyService) emitModelAdapterTestResults(results []ModelAdapterTestResult) {
app := application.Get()
if app == nil {
return
}
app.Event.Emit(modelAdapterTestUpdatedEvent, ModelAdapterTestResultsPayload{
Results: results,
})
}
func buildErroredModelAdapterTestResult(adapterID string, requestHash string, err error) ModelAdapterTestResult {
message := strings.TrimSpace(modelAdapterTestErrorMessage(err))
summary := buildModelAdapterTestErrorSummary(err)
return ModelAdapterTestResult{
AdapterID: strings.TrimSpace(adapterID),
RequestHash: strings.TrimSpace(requestHash),
Status: string(ModelAdapterTestStatusError),
SummaryText: summary,
Error: summary,
RawResponse: message,
TestedAt: time.Now().UTC().Format(time.RFC3339Nano),
}
}
func buildModelAdapterTestSummaryText(result ModelAdapterTestResult) string {
if strings.TrimSpace(result.Status) != string(ModelAdapterTestStatusSuccess) {
return firstNonEmptyTrimmed(result.SummaryText, "测试失败")
}
return fmt.Sprintf("%d t/s | 首字 %s", int(math.Round(maxFloat64(result.TokensPerSecond, 0))), formatModelAdapterTestDuration(result.FirstTextTokenMS))
}
func buildModelAdapterHTTPStatusError(prefix string, resp *http.Response) error {
if resp == nil {
return fmt.Errorf("%s response is nil", strings.TrimSpace(prefix))
}
limitedBody, err := io.ReadAll(io.LimitReader(resp.Body, modelAdapterTestMaxErrorBodyBytes))
if err != nil {
if retrySummary := modeladapter.ProviderRetryAttemptSummary(resp); retrySummary != "" {
return fmt.Errorf("%s status=%d %s body_read_error=%v", strings.TrimSpace(prefix), resp.StatusCode, retrySummary, err)
}
return fmt.Errorf("%s status=%d body_read_error=%v", strings.TrimSpace(prefix), resp.StatusCode, err)
}
retrySummary := modeladapter.ProviderRetryAttemptSummary(resp)
bodyText := strings.TrimSpace(string(limitedBody))
if bodyText == "" {
if retrySummary != "" {
return fmt.Errorf("%s status=%d %s", strings.TrimSpace(prefix), resp.StatusCode, retrySummary)
}
return fmt.Errorf("%s status=%d", strings.TrimSpace(prefix), resp.StatusCode)
}
if retrySummary != "" {
return fmt.Errorf("%s status=%d %s body=%s", strings.TrimSpace(prefix), resp.StatusCode, retrySummary, bodyText)
}
return fmt.Errorf("%s status=%d body=%s", strings.TrimSpace(prefix), resp.StatusCode, bodyText)
}
func buildModelAdapterProviderBodyError(prefix string, body []byte) error {
var payload map[string]any
if err := json.Unmarshal(body, &payload); err != nil {
return nil
}
errorValue, ok := payload["error"]
if !ok || errorValue == nil {
return nil
}
message := ""
details := make([]string, 0, 2)
switch value := errorValue.(type) {
case string:
message = strings.TrimSpace(value)
case map[string]any:
message = strings.TrimSpace(fmt.Sprint(value["message"]))
if errorType := strings.TrimSpace(fmt.Sprint(value["type"])); errorType != "" && errorType != "<nil>" {
details = append(details, "type="+errorType)
}
if code := strings.TrimSpace(fmt.Sprint(value["code"])); code != "" && code != "<nil>" {
details = append(details, "code="+code)
}
default:
message = strings.TrimSpace(fmt.Sprint(value))
}
if message == "" || message == "<nil>" {
message = "provider returned error response"
}
summary := strings.TrimSpace(prefix)
if summary == "" {
summary = "model adapter"
}
if len(details) > 0 {
return fmt.Errorf("%s provider error %s: %s", summary, strings.Join(details, " "), message)
}
return fmt.Errorf("%s provider error: %s", summary, message)
}
func modelAdapterTestErrorMessage(err error) string {
if err == nil {
return ""
}
message := strings.TrimSpace(err.Error())
if message == "" {
return "模型测试失败"
}
if errors.Is(err, context.DeadlineExceeded) {
return "模型测试超时,请稍后重试"
}
return message
}
func buildModelAdapterTestErrorSummary(err error) string {
if err == nil {
return "测试失败"
}
if errors.Is(err, context.DeadlineExceeded) {
return "测试超时"
}
message := strings.TrimSpace(err.Error())
switch {
case strings.Contains(message, modelAdapterTestEmptyTextError):
return "无正文返回"
case strings.Contains(strings.ToLower(message), "context canceled"):
return "测试已停止"
default:
return "测试失败"
}
}
func formatModelAdapterTestDuration(durationMS int64) string {
if durationMS < 1000 {
if durationMS < 0 {
durationMS = 0
}
return fmt.Sprintf("%d ms", durationMS)
}
seconds := float64(durationMS) / 1000
return fmt.Sprintf("%.1f s", seconds)
}
func estimateBenchmarkTextTokens(text string) int64 {
trimmed := strings.TrimSpace(text)
if trimmed == "" {
return 0
}
runeCount := utf8.RuneCountInString(trimmed)
if runeCount <= 0 {
return 0
}
estimated := int64((runeCount + 3) / 4)
estimated += int64(strings.Count(trimmed, "\n"))
if estimated < 1 {
return 1
}
return estimated
}
func buildModelAdapterTestCacheKey(adapter serverconfig.ModelAdapterConfig, requestHash string) string {
baseURL, baseURLErr := modelchannel.NormalizeBaseURL(adapter.BaseURL)
if baseURLErr == nil &&
strings.TrimSpace(adapter.DisplayName) != "" &&
strings.TrimSpace(adapter.ModelID) != "" &&
strings.TrimSpace(adapter.APIKey) != "" {
return modelchannel.BuildChannelID(baseURL, adapter.ModelID, adapter.APIKey, adapter.DisplayName, modelchannel.NormalizeOpenAIEndpoint(adapter.Type, adapter.OpenAIEndpoint))
}
return "invalid:" + strings.TrimSpace(requestHash)
}
func buildModelAdapterTestRequestHash(adapter serverconfig.ModelAdapterConfig) string {
source := normalizeModelAdapterTestHashSource(adapter)
payload := strings.Join([]string{
source.Type,
source.BaseURL,
source.APIKey,
source.ModelID,
source.ReasoningEffort,
source.OpenAIEndpoint,
strconv.Itoa(source.OpenAIExtraParamsEnabled),
source.OpenAIExtraParamsJSON,
strconv.Itoa(source.CustomHeadersEnabled),
source.CustomHeadersJSON,
strconv.Itoa(source.AnthropicExtraParamsEnabled),
source.AnthropicExtraParamsJSON,
strconv.Itoa(source.ContextWindowTokens),
strconv.Itoa(source.MaxCompletionTokens),
strconv.Itoa(source.AnthropicMaxTokens),
source.AnthropicThinkingEffort,
}, "\n")
hasher := fnv.New32a()
_, _ = hasher.Write([]byte(payload))
sum := hasher.Sum(nil)
return hex.EncodeToString(sum)
}
type modelAdapterTestHashSource struct {
Type string
BaseURL string
APIKey string
ModelID string
ReasoningEffort string
OpenAIEndpoint string
OpenAIExtraParamsEnabled int
OpenAIExtraParamsJSON string
CustomHeadersEnabled int
CustomHeadersJSON string
AnthropicExtraParamsEnabled int
AnthropicExtraParamsJSON string
ContextWindowTokens int
MaxCompletionTokens int
AnthropicMaxTokens int
AnthropicThinkingEffort string
}
func normalizeModelAdapterTestHashSource(adapter serverconfig.ModelAdapterConfig) modelAdapterTestHashSource {
baseURL := strings.TrimSpace(adapter.BaseURL)
if normalizedBaseURL, err := modelchannel.NormalizeBaseURL(adapter.BaseURL); err == nil {
baseURL = normalizedBaseURL
}
return modelAdapterTestHashSource{
Type: normalizeModelAdapterTestType(adapter.Type),
BaseURL: baseURL,
APIKey: strings.TrimSpace(adapter.APIKey),
ModelID: strings.TrimSpace(adapter.ModelID),
ReasoningEffort: normalizeModelAdapterTestProviderReasoning(adapter),
OpenAIEndpoint: modelchannel.NormalizeOpenAIEndpoint(adapter.Type, adapter.OpenAIEndpoint),
OpenAIExtraParamsEnabled: normalizeModelAdapterTestBool(adapter.Type == "openai" && adapter.OpenAIExtraParamsEnabled),
OpenAIExtraParamsJSON: normalizeModelAdapterTestOpenAIExtraParamsJSON(adapter),
CustomHeadersEnabled: normalizeModelAdapterTestBool(adapter.CustomHeadersEnabled),
CustomHeadersJSON: normalizeModelAdapterTestCustomHeadersJSON(adapter),
AnthropicExtraParamsEnabled: normalizeModelAdapterTestBool(adapter.Type == "anthropic" && adapter.AnthropicExtraParamsEnabled),
AnthropicExtraParamsJSON: normalizeModelAdapterTestAnthropicExtraParamsJSON(adapter),
ContextWindowTokens: normalizeModelAdapterTestInt(adapter.ContextWindowTokens),
MaxCompletionTokens: normalizeModelAdapterTestInt(adapter.MaxCompletionTokens),
AnthropicMaxTokens: normalizeModelAdapterTestInt(adapter.AnthropicMaxTokens),
AnthropicThinkingEffort: normalizeModelAdapterTestProviderAnthropicThinkingEffort(adapter),
}
}
func normalizeModelAdapterTestType(value string) string {
switch strings.ToLower(strings.TrimSpace(value)) {
case "anthropic":
return "anthropic"
case "openai":
return "openai"
default:
return strings.ToLower(strings.TrimSpace(value))
}
}
func normalizeModelAdapterTestReasoning(value string) string {
switch strings.ToLower(strings.TrimSpace(value)) {
case "low", "medium", "high", "xhigh":
return strings.ToLower(strings.TrimSpace(value))
default:
return "medium"
}
}
func normalizeModelAdapterTestProviderReasoning(adapter serverconfig.ModelAdapterConfig) string {
if normalizeModelAdapterTestType(adapter.Type) != "openai" {
return ""
}
return normalizeModelAdapterTestReasoning(adapter.ReasoningEffort)
}
func normalizeModelAdapterTestAnthropicThinkingEffort(value string) string {
switch strings.ToLower(strings.TrimSpace(value)) {
case "low", "medium", "high", "xhigh", "max":
return strings.ToLower(strings.TrimSpace(value))
default:
return "xhigh"
}
}
func normalizeModelAdapterTestProviderAnthropicThinkingEffort(adapter serverconfig.ModelAdapterConfig) string {
if normalizeModelAdapterTestType(adapter.Type) != "anthropic" {
return ""
}
return normalizeModelAdapterTestAnthropicThinkingEffort(adapter.AnthropicThinkingEffort)
}
func modelAdapterTestConfiguredAnthropicMaxTokens(adapter serverconfig.ModelAdapterConfig) int {
if adapter.AnthropicMaxTokens > 0 {
return adapter.AnthropicMaxTokens
}
if adapter.MaxCompletionTokens > 0 {
return adapter.MaxCompletionTokens
}
return modelAdapterTestDefaultMaxTokens
}
func modelAdapterTestConfiguredOpenAIMaxTokens(adapter serverconfig.ModelAdapterConfig) int {
if adapter.MaxCompletionTokens > 0 {
return adapter.MaxCompletionTokens
}
if adapter.AnthropicMaxTokens > 0 {
return adapter.AnthropicMaxTokens
}
return modelAdapterTestDefaultMaxTokens
}
func normalizeModelAdapterTestBool(value bool) int {
if value {
return 1
}
return 0
}
func normalizeModelAdapterTestOpenAIExtraParamsJSON(adapter serverconfig.ModelAdapterConfig) string {
if normalizeModelAdapterTestType(adapter.Type) != "openai" || !adapter.OpenAIExtraParamsEnabled {
return ""
}
return strings.TrimSpace(adapter.OpenAIExtraParamsJSON)
}
func normalizeModelAdapterTestCustomHeadersJSON(adapter serverconfig.ModelAdapterConfig) string {
if !adapter.CustomHeadersEnabled {
return ""
}
return strings.TrimSpace(adapter.CustomHeadersJSON)
}
func normalizeModelAdapterTestAnthropicExtraParamsJSON(adapter serverconfig.ModelAdapterConfig) string {
if normalizeModelAdapterTestType(adapter.Type) != "anthropic" || !adapter.AnthropicExtraParamsEnabled {
return ""
}
return strings.TrimSpace(adapter.AnthropicExtraParamsJSON)
}
func normalizeModelAdapterTestInt(value int) int {
if value <= 0 {
return 0
}
return value
}
func maxFloat64(value float64, fallback float64) float64 {
if value < fallback {
return fallback
}
return value
}
func firstNonEmptyTrimmed(values ...string) string {
for _, value := range values {
trimmed := strings.TrimSpace(value)
if trimmed != "" {
return trimmed
}
}
return ""
}

View File

@@ -0,0 +1,136 @@
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
}

View File

@@ -0,0 +1,76 @@
package client
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestListRemoteModelsResolvesEndpointForOpenAIBaseURLShapes(t *testing.T) {
cases := []struct {
name string
adapterType string
baseURL string
wantPath string
}{
{"openai 裸 host 追加 /v1/models", "openai", "/api", "/api/v1/models"},
{"openai base 以 /v1 结尾仅追加 /models", "openai", "/v1", "/v1/models"},
{"openai base 内嵌 /v1/chat/completions 剥离方法段", "openai", "/v1/chat/completions", "/v1/models"},
{"openai base 内嵌 /v1/responses 剥离方法段", "openai", "/v1/responses", "/v1/models"},
{"anthropic 裸 host 追加 /v1/models", "anthropic", "/api", "/api/v1/models"},
{"anthropic base 以 /v1 结尾仅追加 /models", "anthropic", "/v1", "/v1/models"},
{"openai base 大小写混合时按 /V1 结尾去重", "openai", "/V1", "/V1/models"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
var gotPath string
var gotAuth string
var gotAPIKey string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath = r.URL.Path
gotAuth = r.Header.Get("Authorization")
gotAPIKey = r.Header.Get("x-api-key")
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"data": []map[string]any{{"id": "gpt-test"}},
})
}))
defer server.Close()
baseURL := server.URL + tc.baseURL
service := &ProxyService{}
result, err := service.ListRemoteModels(tc.adapterType, baseURL, "sk-test")
if err != nil {
t.Fatalf("ListRemoteModels() error = %v", err)
}
if gotPath != tc.wantPath {
t.Fatalf("请求路径 = %s, 期望 %s", gotPath, tc.wantPath)
}
if len(result.Models) != 1 || result.Models[0].ID != "gpt-test" {
t.Fatalf("模型解析结果异常: %+v", result.Models)
}
if tc.adapterType == "openai" {
if gotAuth != "Bearer sk-test" {
t.Fatalf("openai 期望 Authorization Bearer 头, got %q", gotAuth)
}
} else if gotAPIKey != "sk-test" {
t.Fatalf("anthropic 期望 x-api-key 头, got %q", gotAPIKey)
}
})
}
}
func TestBuildModelsEndpointDefaults(t *testing.T) {
if got := buildModelsEndpoint("openai", "https://example.com"); got != "https://example.com/v1/models" {
t.Fatalf("裸 host 解析 = %s", got)
}
if got := buildModelsEndpoint("anthropic", "https://example.com/v1/"); got != "https://example.com/v1/models" {
t.Fatalf("/v1 结尾解析 = %s", got)
}
if !strings.HasPrefix(buildModelsEndpoint("openai", "https://example.com/v1"), "https://example.com/v1/") {
t.Fatalf("/v1 结尾去重后应保留原路径前缀")
}
}

View File

@@ -0,0 +1,21 @@
package client
import (
"context"
localruntime "cursor/internal/runtime"
)
// runtimeConfigSnapshot 用于处理与 runtimeConfigSnapshot 相关的逻辑。
func (s *ProxyService) runtimeConfigSnapshot(_ context.Context) (localruntime.RuntimeConfigSnapshot, error) {
if s == nil {
return localruntime.RuntimeConfigSnapshot{}, nil
}
if s.backendHost != nil && s.backendHost.ConfigManager() != nil {
return s.backendHost.ConfigManager().LegacyRuntimeSnapshot(context.Background())
}
if s.store == nil {
return localruntime.RuntimeConfigSnapshot{}, nil
}
return s.store.LegacyRuntimeSnapshot(context.Background())
}

167
internal/client/service.go Normal file
View File

@@ -0,0 +1,167 @@
package client
import (
"context"
"fmt"
"net/http"
"sync"
"time"
"cursor/internal/appdata"
backend "cursor/internal/backend"
serverconfig "cursor/internal/backend/server/config"
"cursor/internal/certs"
"cursor/internal/logger"
"cursor/internal/mitm"
"cursor/internal/netproxy"
)
const (
// publicAPITimeout 表示当前模块中的 publicAPITimeout 状态值。
publicAPITimeout = 15 * time.Second
// backendReadyTimeout 表示等待嵌入式 backend 就绪的最长时间。
backendReadyTimeout = 15 * time.Second
// backendHealthCheckInterval 表示轮询 backend 健康检查的间隔。
backendHealthCheckInterval = 1 * time.Second
// backendHealthCheckAttemptTimeout 限制单次健康检查耗时,避免一次阻塞吃掉全部启动预算。
backendHealthCheckAttemptTimeout = 1 * time.Second
)
// ProxyService 定义了当前模块中的 ProxyService 类型。
type ProxyService struct {
// proxy 表示 IDE 版 MITM 代理。
proxy *mitm.ProxyServer
// certManager 用于在代理监听地址变化时重建 MITM 服务,同时为 CLI HTTPS 监听器签发证书。
certManager *certs.Manager
// backendHost 表示当前嵌入式 backend 服务。
backendHost *backend.Host
// mu 表示当前声明中的 mu。
mu sync.RWMutex
// lastError 表示当前声明中的 lastError。
lastError string
// cursorSettingsApplied 表示当前是否已完成宿主代理设置注入。
cursorSettingsApplied bool
// configMu 表示当前声明中的 configMu。
configMu sync.Mutex
// configPath 表示当前声明中的 configPath。
configPath string
// store 表示统一的配置存储。
store *serverconfig.Store
// caCertPEM 表示当前声明中的 caCertPEM。
caCertPEM []byte
// caFileMu 表示当前声明中的 caFileMu。
caFileMu sync.Mutex
// caFilePath 表示当前声明中的 caFilePath。
caFilePath string
// publicClient 表示当前声明中的 publicClient。
publicClient *http.Client
// logsRoot 表示当前声明中的 logsRoot。
logsRoot string
// modelTestMu 保护模型测速缓存。
modelTestMu sync.RWMutex
// modelTestResults 保存当前进程内的模型测速结果。
modelTestResults map[string]ModelAdapterTestResult
}
// NewProxyService 用于处理与 NewProxyService 相关的逻辑。
func NewProxyService(proxy *mitm.ProxyServer, certManager *certs.Manager, caCertPEM []byte) *ProxyService {
if err := appdata.EnsureAssistantHome(); err != nil {
logger.Errorf("ensure assistant home failed: %v", err)
}
copiedCert := make([]byte, len(caCertPEM))
copy(copiedCert, caCertPEM)
service := &ProxyService{
proxy: proxy,
certManager: certManager,
configPath: resolveUserConfigPath(),
logsRoot: resolveLogsRootPath(),
caCertPEM: copiedCert,
publicClient: netproxy.NewHTTPClient(publicAPITimeout),
modelTestResults: make(map[string]ModelAdapterTestResult),
}
service.store = serverconfig.NewStore(service.configPath, service.logsRoot)
host, err := backend.NewHost(service.store, service.certManager)
if err != nil {
logger.Errorf("init backend host failed: %v", err)
} else {
service.backendHost = host
}
return service
}
func (s *ProxyService) ensureBackendHost() error {
if s == nil {
return nil
}
if s.backendHost != nil {
return nil
}
host, err := backend.NewHost(s.store, s.certManager)
if err != nil {
return err
}
s.backendHost = host
return nil
}
func (s *ProxyService) ensureProxy(cfg serverconfig.Config) error {
if s == nil {
return nil
}
baseURL := ""
if s.backendHost != nil {
baseURL = s.backendHost.BaseURL()
}
if baseURL == "" {
baseURL = "http://" + cfg.BackendListenAddr
}
listenAddr := cfg.ProxyListenAddr
if s.proxy != nil {
snapshot := s.proxy.Snapshot()
if snapshot.ListenAddr == listenAddr {
return s.proxy.UpdateBaseURL(baseURL)
}
if snapshot.Running {
return fmt.Errorf("代理正在运行,不能从 %s 切换到 %s,请先停止服务", snapshot.ListenAddr, listenAddr)
}
}
proxyServer, err := mitm.NewProxyServer(listenAddr, baseURL, "", "", s.certManager)
if err != nil {
return err
}
s.proxy = proxyServer
return nil
}
func (s *ProxyService) waitForBackend(ctx context.Context) error {
if s == nil || s.backendHost == nil {
return nil
}
ticker := time.NewTicker(backendHealthCheckInterval)
defer ticker.Stop()
var lastErr error
for {
healthCtx, healthCancel := context.WithTimeout(ctx, backendHealthCheckAttemptTimeout)
err := s.backendHost.HealthCheck(healthCtx)
healthCancel()
if err == nil {
return nil
}
lastErr = err
select {
case <-ctx.Done():
if lastErr != nil {
return fmt.Errorf("等待内置后端就绪失败: %w", lastErr)
}
return ctx.Err()
case <-ticker.C:
}
}
}