This commit is contained in:
leokun
2026-06-30 10:38:52 +08:00
commit c083be5ec2
312 changed files with 146628 additions and 0 deletions
+88
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()
}
+63
View File
@@ -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()
}
+2
View File
@@ -0,0 +1,2 @@
// Package clientruntime 负责协调本地客户端运行时、配置、状态与外部适配逻辑。
package client
+145
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)
}
+277
View File
@@ -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
}
+771
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 ""
}
+21
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
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 表示当前声明中的 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:
}
}
}