mirror of
https://wget.la/https://github.com/leookun/cursor-byok
synced 2026-08-17 03:27:02 +08:00
1049 lines
34 KiB
Go
1049 lines
34 KiB
Go
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 ""
|
||
}
|