mirror of
https://wget.la/https://github.com/leookun/cursor-byok
synced 2026-08-18 03:57:06 +08:00
v0.3.8
This commit is contained in:
@@ -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()
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
goruntime "runtime"
|
||||
|
||||
"cursor/internal/cursor"
|
||||
)
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
if err := cursor.WriteUserProxySettings(cursor.ProxyURLFromListenAddr(s.proxy.Snapshot().ListenAddr)); err != nil {
|
||||
return err
|
||||
}
|
||||
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
|
||||
}
|
||||
s.setCursorSettingsApplied(false)
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetDeviceID 用于处理与 GetDeviceID 相关的逻辑。
|
||||
func (s *ProxyService) GetDeviceID() (string, error) {
|
||||
return cursor.GetDeviceID()
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
// Package clientruntime 负责协调本地客户端运行时、配置、状态与外部适配逻辑。
|
||||
package client
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
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"`
|
||||
// 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 相关的逻辑。
|
||||
func (s *ProxyService) StopProxy() (ProxyState, error) {
|
||||
logger.Infof("stop service requested")
|
||||
fail := func(step string, err error) (ProxyState, error) {
|
||||
logger.Errorf("stop service failed step=%s err=%v", step, err)
|
||||
s.setLastError(err)
|
||||
s.emitState()
|
||||
return s.GetState(), 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)
|
||||
if err := s.proxy.Stop(ctx); err != nil {
|
||||
return fail("stop_mitm_proxy", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := s.ClearCursorSettings(); err != nil {
|
||||
return fail("clear_cursor_settings", err)
|
||||
}
|
||||
if s.backendHost != nil {
|
||||
logger.Infof("stopping embedded backend listen_addr=%s", s.backendHost.ListenAddr())
|
||||
if err := s.backendHost.Stop(ctx); err != nil && !errors.Is(err, context.Canceled) {
|
||||
return fail("stop_backend", err)
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
if s.backendHost != nil {
|
||||
backendListenAddr = s.backendHost.ListenAddr()
|
||||
backendRunning = s.backendHost.IsRunning()
|
||||
}
|
||||
netProxy := netproxy.CurrentStatus()
|
||||
return ProxyState{
|
||||
ListenAddr: proxySnap.ListenAddr,
|
||||
Running: proxySnap.Running,
|
||||
BackendListenAddr: backendListenAddr,
|
||||
BackendRunning: backendRunning,
|
||||
ProxyListenAddr: proxySnap.ListenAddr,
|
||||
ProxyRunning: proxySnap.Running,
|
||||
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
|
||||
}
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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 表示当前声明中的 proxy。
|
||||
proxy *mitm.ProxyServer
|
||||
// certManager 用于在代理监听地址变化时重建 MITM 服务。
|
||||
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)
|
||||
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)
|
||||
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:
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user