Files
cursor-byok/internal/client/model_adapter_benchmark.go
T

1049 lines
34 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package client
import (
"context"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"hash/fnv"
"io"
"net/http"
"net/url"
"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
modelAdapterListTimeout = 20 * time.Second
modelAdapterListMaxBodyBytes = 8 << 20
modelAdapterListPageSize = 1000
modelAdapterListMaxPages = 50
)
// modelListProviderRule 收敛各家模型列表接口的协议差异,避免判断散落到多个函数。
type modelListProviderRule struct {
// paths 按优先级排列,逐个尝试直到某个返回可用模型
paths []string
authHeader string
authPrefix string
extraHeader map[string]string
// paginated 为真时按 limit + after_id 游标翻页,直到 has_more 为 false
paginated bool
}
var modelListProviderRules = map[string]modelListProviderRule{
"openai": {
paths: []string{"/models"},
authHeader: "Authorization",
authPrefix: "Bearer ",
},
"anthropic": {
paths: []string{"/models"},
authHeader: "x-api-key",
extraHeader: map[string]string{"anthropic-version": "2023-06-01"},
paginated: true,
},
}
// modelListVersionSegments 用于判断 base url 是否已带版本前缀,带了就不再补 /v1。
var modelListVersionSegments = map[string]bool{
"v1": true,
"v1beta": true,
"v2": true,
"beta": true,
"openai": true,
"compat": true,
"compatible": true,
}
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"`
}
// ModelAdapterModelsRequest 定义从兼容接口读取模型列表所需的最小配置。
type ModelAdapterModelsRequest struct {
Type string `json:"type"`
BaseURL string `json:"baseURL"`
APIKey string `json:"apiKey"`
CustomHeadersEnabled bool `json:"customHeadersEnabled"`
CustomHeadersJSON string `json:"customHeadersJSON"`
}
// ModelAdapterModelsResult 定义可供前端下拉选择的模型列表。
type ModelAdapterModelsResult struct {
Models []string `json:"models"`
}
// 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) FetchModelAdapterModels(input ModelAdapterModelsRequest) (ModelAdapterModelsResult, error) {
_ = s
provider := strings.ToLower(strings.TrimSpace(input.Type))
baseURL := strings.TrimSpace(input.BaseURL)
apiKey := strings.TrimSpace(input.APIKey)
rule, supported := modelListProviderRules[provider]
if !supported {
return ModelAdapterModelsResult{}, errors.New("模型类型仅支持 OpenAI 或 Anthropic")
}
if baseURL == "" {
return ModelAdapterModelsResult{}, errors.New("接口地址不能为空")
}
if apiKey == "" {
return ModelAdapterModelsResult{}, errors.New("访问密钥不能为空")
}
ctx, cancel := context.WithTimeout(context.Background(), modelAdapterListTimeout)
defer cancel()
var lastErr error
for _, endpoint := range buildModelListEndpointCandidates(rule, baseURL) {
models, err := fetchModelListEndpoint(ctx, rule, endpoint, apiKey, input)
if err == nil {
return ModelAdapterModelsResult{Models: models}, nil
}
lastErr = err
}
if lastErr != nil {
return ModelAdapterModelsResult{}, lastErr
}
return ModelAdapterModelsResult{}, errors.New("未找到可用的模型列表接口")
}
func buildModelListEndpointCandidates(rule modelListProviderRule, rawBaseURL string) []string {
base := strings.TrimRight(strings.TrimSpace(rawBaseURL), "/")
for _, suffix := range []string{"/chat/completions", "/responses", "/messages"} {
if strings.HasSuffix(strings.ToLower(base), suffix) {
base = base[:len(base)-len(suffix)]
}
}
base = strings.TrimRight(base, "/")
tail := strings.ToLower(base[strings.LastIndex(base, "/")+1:])
var candidates []string
switch {
case tail == "models" || tail == "model":
// 用户已经填到模型列表地址本身,直接用
candidates = []string{base}
case modelListVersionSegments[tail]:
candidates = prefixModelListPaths(base, "", rule.paths)
default:
// base 没带版本段,优先试 /v1,再退回裸路径
candidates = append(
prefixModelListPaths(base, "/v1", rule.paths),
prefixModelListPaths(base, "", rule.paths)...,
)
}
seen := map[string]struct{}{}
endpoints := make([]string, 0, len(candidates))
for _, endpoint := range candidates {
if _, err := url.ParseRequestURI(endpoint); err != nil {
continue
}
if _, exists := seen[endpoint]; exists {
continue
}
seen[endpoint] = struct{}{}
endpoints = append(endpoints, endpoint)
}
return endpoints
}
func prefixModelListPaths(base string, version string, paths []string) []string {
endpoints := make([]string, 0, len(paths))
for _, path := range paths {
endpoints = append(endpoints, base+version+path)
}
return endpoints
}
func fetchModelListEndpoint(ctx context.Context, rule modelListProviderRule, endpoint string, apiKey string, input ModelAdapterModelsRequest) ([]string, error) {
collected := []string{}
cursor := ""
for page := 0; page < modelAdapterListMaxPages; page++ {
requestURL := endpoint
if rule.paginated {
requestURL = appendModelListCursor(endpoint, cursor)
}
payload, err := requestModelListPayload(ctx, rule, requestURL, apiKey, input)
if err != nil {
return nil, err
}
collected = append(collected, extractModelIDs(payload)...)
if !rule.paginated {
break
}
cursor = nextModelListCursor(payload)
if cursor == "" {
break
}
if page == modelAdapterListMaxPages-1 {
return nil, fmt.Errorf("模型列表分页超过 %d 页,结果可能不完整", modelAdapterListMaxPages)
}
}
models := normalizeFetchedModelIDs(collected)
if len(models) == 0 {
return nil, errors.New("模型列表响应中没有可用模型")
}
return models, nil
}
func requestModelListPayload(
ctx context.Context,
rule modelListProviderRule,
requestURL string,
apiKey string,
input ModelAdapterModelsRequest,
) (any, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, requestURL, nil)
if err != nil {
return nil, err
}
req.Header.Set(rule.authHeader, rule.authPrefix+apiKey)
for key, value := range rule.extraHeader {
req.Header.Set(key, value)
}
req.Header.Set("Accept", "application/json")
applyModelListCustomHeaders(req.Header, input)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
body, readErr := io.ReadAll(io.LimitReader(resp.Body, modelAdapterListMaxBodyBytes))
if readErr != nil {
return nil, readErr
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
message := strings.TrimSpace(string(body))
if len(message) > modelAdapterTestMaxErrorBodyBytes {
message = message[:modelAdapterTestMaxErrorBodyBytes]
}
if message == "" {
message = resp.Status
}
return nil, fmt.Errorf("读取模型列表失败:%s", message)
}
var payload any
if err := json.Unmarshal(body, &payload); err != nil {
return nil, fmt.Errorf("模型列表响应不是合法 JSON%w", err)
}
return payload, nil
}
func appendModelListCursor(endpoint string, cursor string) string {
query := url.Values{}
query.Set("limit", strconv.Itoa(modelAdapterListPageSize))
if cursor != "" {
query.Set("after_id", cursor)
}
separator := "?"
if strings.Contains(endpoint, "?") {
separator = "&"
}
return endpoint + separator + query.Encode()
}
func nextModelListCursor(payload any) string {
object, ok := payload.(map[string]any)
if !ok {
return ""
}
if hasMore, _ := object["has_more"].(bool); !hasMore {
return ""
}
cursor, _ := object["last_id"].(string)
return strings.TrimSpace(cursor)
}
func applyModelListCustomHeaders(header http.Header, input ModelAdapterModelsRequest) {
if !input.CustomHeadersEnabled || strings.TrimSpace(input.CustomHeadersJSON) == "" {
return
}
var parsed map[string]string
if err := json.Unmarshal([]byte(input.CustomHeadersJSON), &parsed); err != nil {
return
}
for key, value := range parsed {
if strings.TrimSpace(key) == "" {
continue
}
header.Set(key, value)
}
}
func extractModelIDs(value any) []string {
switch typed := value.(type) {
case string:
if strings.TrimSpace(typed) == "" {
return []string{}
}
return []string{typed}
case []any:
models := make([]string, 0, len(typed))
for _, item := range typed {
models = append(models, extractModelIDs(item)...)
}
return models
case map[string]any:
for _, key := range []string{"id", "name"} {
if text, ok := typed[key].(string); ok && strings.TrimSpace(text) != "" {
return []string{text}
}
}
models := []string{}
for _, key := range []string{"data", "models"} {
if child, ok := typed[key]; ok {
models = append(models, extractModelIDs(child)...)
}
}
return models
default:
return []string{}
}
}
func normalizeFetchedModelIDs(input []string) []string {
seen := map[string]struct{}{}
models := make([]string, 0, len(input))
for _, item := range input {
model := strings.TrimSpace(item)
if model == "" {
continue
}
if _, exists := seen[model]; exists {
continue
}
seen[model] = struct{}{}
models = append(models, model)
}
sort.Strings(models)
return models
}
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),
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),
}
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 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 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 "":
return ""
case "low", "medium", "high", "xhigh", "max":
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 firstNonEmptyTrimmed(values ...string) string {
for _, value := range values {
trimmed := strings.TrimSpace(value)
if trimmed != "" {
return trimmed
}
}
return ""
}