Files
cursor-byok/internal/backend/agent/model/router.go
T
leookun 0f6b0f9484 release: 0.0.41
- 修复内存泄漏问题,该可能导致内存异常占用
- 支持俄语增加翻译范围
- 修复qwen-3.8-max中断问题(mimo也应该属于同一类问题)
- 修复claude模型可能无法识别图片问题 @GGHansome
- 支持自定义Openai端点 @Sxuan-Coder
- WebSearch 接入百度搜索,DuckDuckGo 作为兜底 @杨超
- 修复一些兼容性问题 @kael-odin
- 修复window上一些表现问题 @philau2512

🔔 如何让AI自动拉模型配置? (以下为提示词,把地址和密钥换为你的)
我的模型配置在 ~/.cursor-local-assistant-v2/config.yaml,我的API地址是:https://xxx 密钥是xxx,帮我拉所有模型配置进去,不要影响已有模型,根据models标准接口拉取。
2026-07-27 00:55:02 +08:00

420 lines
14 KiB
Go

// router.go 按模型标识选择 OpenAI 或 Anthropic 兼容适配器。
package modeladapter
import (
"context"
"fmt"
"strings"
"time"
legacyruntime "cursor/internal/runtime"
)
// Router 是 MVP 阶段的模型适配路由器。
type Router struct {
// openai 负责 OpenAI 兼容流式请求。
openai ModelAdapter
// anthropic 负责 Anthropic 兼容流式请求。
anthropic ModelAdapter
// resolver 负责从本地配置中解析实际模型通道。
resolver ChannelResolver
}
type ChannelResolver interface {
SelectChannelForModel(context.Context, string) (*legacyruntime.ResolvedChannel, error)
ProviderStreamIdleTimeout(context.Context) time.Duration
}
// NewRouter 创建模型适配路由器。
func NewRouter(resolver ChannelResolver) *Router {
return &Router{
openai: NewOpenAIAdapter(),
anthropic: NewAnthropicAdapter(),
resolver: resolver,
}
}
// Stream 根据模型标识选择具体 provider 并转发请求。
func (router *Router) Stream(ctx context.Context, req StreamRequest, sink func(ModelEvent) error) error {
if router == nil || router.resolver == nil {
return fmt.Errorf("model adapter resolver is unavailable")
}
channel, err := router.resolver.SelectChannelForModel(ctx, req.ModelID)
if err != nil {
return err
}
if channel == nil {
return fmt.Errorf("no available channel for model %q", req.ModelID)
}
resolved := req
resolved.Provider = strings.TrimSpace(channel.Provider)
resolved.BaseURL = strings.TrimSpace(channel.BaseURL)
resolved.APIKey = strings.TrimSpace(channel.APIKey)
resolved.ProviderModelID = strings.TrimSpace(channel.Model)
resolved.ResolvedChannelID = strings.TrimSpace(channel.ID)
resolved.ResolvedChannelName = strings.TrimSpace(channel.Name)
resolved.ResolvedContextWindowTokens = channel.ContextWindowTokens
resolved.ReasoningEffort = openAIReasoningEffortFromRuntime(channel.ReasoningEffort)
resolved.OpenAIEndpoint = strings.TrimSpace(channel.OpenAIEndpoint)
resolved.OpenAIExtraParamsEnabled = channel.OpenAIExtraParamsEnabled
resolved.OpenAIExtraParamsJSON = strings.TrimSpace(channel.OpenAIExtraParamsJSON)
resolved.CustomHeadersEnabled = channel.CustomHeadersEnabled
resolved.CustomHeadersJSON = strings.TrimSpace(channel.CustomHeadersJSON)
resolved.AnthropicExtraParamsEnabled = channel.AnthropicExtraParamsEnabled
resolved.AnthropicExtraParamsJSON = strings.TrimSpace(channel.AnthropicExtraParamsJSON)
resolved.AnthropicMaxTokens = channel.AnthropicMaxTokens
resolved.AnthropicThinkingEffort = strings.TrimSpace(channel.AnthropicThinkingEffort)
resolved.ThinkingBudgetTokens = channel.ThinkingBudgetTokens
resolved.ProviderStreamIdleTimeout = router.resolver.ProviderStreamIdleTimeout(ctx)
runtimeThinkingEffort := normalizeRuntimeThinkingEffort(req.ThinkingEffort)
if runtimeThinkingEffort != "" {
resolved.ThinkingEffort = runtimeThinkingEffort
if runtimeThinkingEffort == "disabled" {
resolved.ReasoningEffort = ""
resolved.AnthropicThinkingEffort = ""
} else {
resolved.ReasoningEffort = openAIReasoningEffortFromRuntime(runtimeThinkingEffort)
resolved.AnthropicThinkingEffort = runtimeThinkingEffort
}
} else {
resolved.ThinkingEffort = ""
}
if resolved.MaxTokens <= 0 && channel.MaxTokens > 0 {
resolved.MaxTokens = channel.MaxTokens
}
if req.MaxTokens > 0 && (resolved.AnthropicMaxTokens <= 0 || req.MaxTokens < resolved.AnthropicMaxTokens) {
resolved.AnthropicMaxTokens = req.MaxTokens
}
if resolved.AnthropicMaxTokens <= 0 && resolved.MaxTokens > 0 {
resolved.AnthropicMaxTokens = resolved.MaxTokens
}
if resolved.ProviderModelID == "" {
resolved.ProviderModelID = strings.TrimSpace(req.ModelID)
}
resolved.Messages = sanitizeProviderMessages(req.Messages)
if resolved.RequestKnobs != nil {
resolved.RequestKnobs["max_tokens"] = resolved.MaxTokens
if runtimeThinkingEffort != "" {
resolved.RequestKnobs["runtime_thinking_effort"] = runtimeThinkingEffort
} else {
delete(resolved.RequestKnobs, "runtime_thinking_effort")
}
if resolved.Provider == "openai" {
if strings.TrimSpace(resolved.ReasoningEffort) != "" {
resolved.RequestKnobs["reasoning_effort"] = strings.TrimSpace(resolved.ReasoningEffort)
} else {
delete(resolved.RequestKnobs, "reasoning_effort")
}
resolved.RequestKnobs["openai_endpoint"] = resolved.OpenAIEndpoint
resolved.RequestKnobs["openai_extra_params_enabled"] = resolved.OpenAIExtraParamsEnabled
resolved.RequestKnobs["custom_headers_enabled"] = resolved.CustomHeadersEnabled
} else if resolved.Provider == "anthropic" {
delete(resolved.RequestKnobs, "reasoning_effort")
resolved.RequestKnobs["custom_headers_enabled"] = resolved.CustomHeadersEnabled
resolved.RequestKnobs["anthropic_extra_params_enabled"] = resolved.AnthropicExtraParamsEnabled
anthropicMaxTokens := maxAnthropicTokens(resolved)
resolved.RequestKnobs["max_tokens"] = anthropicMaxTokens
resolved.RequestKnobs["anthropic_max_tokens"] = anthropicMaxTokens
if strings.TrimSpace(resolved.AnthropicThinkingEffort) != "" {
resolved.RequestKnobs["anthropic_thinking_effort"] = anthropicThinkingEffort(resolved)
} else {
delete(resolved.RequestKnobs, "anthropic_thinking_effort")
}
}
}
switch resolved.Provider {
case "anthropic":
return router.anthropic.Stream(ctx, resolved, sink)
case "openai":
return router.openai.Stream(ctx, resolved, sink)
default:
return fmt.Errorf("unsupported provider %q", resolved.Provider)
}
}
// sanitizeProviderMessages removes replay-only placeholders and trims trailing
// assistant prefill so providers that require a user/tool terminal message do
// not reject the request.
func sanitizeProviderMessages(input []Message) []Message {
if len(input) == 0 {
return nil
}
filtered := make([]Message, 0, len(input))
for _, message := range input {
if isAssistantPlaceholderMessage(message) {
continue
}
filtered = append(filtered, message)
}
filtered = mergeAdjacentAssistantToolCallMessages(filtered)
filtered = trimDanglingAssistantToolCalls(filtered)
for len(filtered) > 0 && isAssistantPrefillMessage(filtered[len(filtered)-1]) {
filtered = filtered[:len(filtered)-1]
}
if len(filtered) == 0 {
return nil
}
return filtered
}
func isAssistantPlaceholderMessage(message Message) bool {
if strings.TrimSpace(message.Role) != "assistant" {
return false
}
if len(message.ToolCalls) > 0 || len(message.ContentParts) > 0 {
return false
}
if strings.TrimSpace(message.ToolCallID) != "" || strings.TrimSpace(message.Name) != "" {
return false
}
if strings.TrimSpace(message.ReasoningContent) != "" {
return false
}
if strings.TrimSpace(message.ReasoningSignature) != "" {
return false
}
switch strings.TrimSpace(message.Content) {
case "":
return true
default:
return false
}
}
func isAssistantPrefillMessage(message Message) bool {
if strings.TrimSpace(message.Role) != "assistant" {
return false
}
if len(message.ToolCalls) > 0 {
return false
}
if strings.TrimSpace(message.ToolCallID) != "" || strings.TrimSpace(message.Name) != "" {
return false
}
return strings.TrimSpace(message.Content) != "" || strings.TrimSpace(message.ReasoningContent) != ""
}
func mergeAdjacentAssistantToolCallMessages(input []Message) []Message {
if len(input) == 0 {
return nil
}
merged := make([]Message, 0, len(input))
for _, raw := range input {
message := cloneProviderMessage(raw)
if mergeProviderAssistantToolCalls(&merged, message) {
continue
}
merged = append(merged, message)
}
return merged
}
func cloneProviderMessage(message Message) Message {
cloned := message
if len(message.ContentParts) > 0 {
cloned.ContentParts = append([]ContentPart(nil), message.ContentParts...)
}
if len(message.ToolCalls) > 0 {
cloned.ToolCalls = append([]ToolCallDescriptor(nil), message.ToolCalls...)
}
if len(message.OpenAIResponsesReasoningSummary) > 0 {
cloned.OpenAIResponsesReasoningSummary = append([]byte(nil), message.OpenAIResponsesReasoningSummary...)
}
return cloned
}
func mergeProviderAssistantToolCalls(messages *[]Message, message Message) bool {
if len(*messages) == 0 {
return false
}
last := &(*messages)[len(*messages)-1]
if !canMergeProviderAssistantToolCalls(*last, message) {
return false
}
startIndex := len(last.ToolCalls)
for index, toolCall := range message.ToolCalls {
item := toolCall
item.Index = startIndex + index
last.ToolCalls = append(last.ToolCalls, item)
}
last.ReasoningContent = mergeProviderReasoning(last.ReasoningContent, message.ReasoningContent)
mergeProviderReasoningMetadata(last, message)
return true
}
func canMergeProviderAssistantToolCalls(last Message, current Message) bool {
if strings.TrimSpace(last.Role) != "assistant" || strings.TrimSpace(current.Role) != "assistant" {
return false
}
if len(current.ToolCalls) == 0 {
return false
}
if strings.TrimSpace(last.ToolCallID) != "" || strings.TrimSpace(last.Name) != "" {
return false
}
if strings.TrimSpace(current.ToolCallID) != "" || strings.TrimSpace(current.Name) != "" {
return false
}
if strings.TrimSpace(current.Content) != "" || len(current.ContentParts) > 0 {
return false
}
return true
}
func mergeProviderReasoning(left string, right string) string {
left = strings.TrimSpace(left)
right = strings.TrimSpace(right)
switch {
case left == "":
return right
case right == "", right == left:
return left
default:
return left + "\n\n" + right
}
}
func mergeProviderReasoningSignature(left string, right string) string {
left = strings.TrimSpace(left)
right = strings.TrimSpace(right)
switch {
case left == "":
return right
case right == "", right == left:
return left
default:
return ""
}
}
func mergeProviderReasoningSignatureSource(left string, right string) string {
left = strings.TrimSpace(left)
right = strings.TrimSpace(right)
switch {
case left == "":
return right
case right == "", right == left:
return left
default:
return ""
}
}
func mergeProviderReasoningMetadata(last *Message, current Message) {
if last == nil {
return
}
leftSignature := strings.TrimSpace(last.ReasoningSignature)
rightSignature := strings.TrimSpace(current.ReasoningSignature)
mergedSignature := mergeProviderReasoningSignature(leftSignature, rightSignature)
last.ReasoningSignature = mergedSignature
if mergedSignature == "" {
last.ReasoningSignatureSource = ""
last.OpenAIResponsesReasoningID = ""
last.OpenAIResponsesReasoningStatus = ""
last.OpenAIResponsesReasoningSummary = nil
return
}
if leftSignature == "" && rightSignature != "" {
last.ReasoningSignatureSource = strings.TrimSpace(current.ReasoningSignatureSource)
last.OpenAIResponsesReasoningID = current.OpenAIResponsesReasoningID
last.OpenAIResponsesReasoningStatus = current.OpenAIResponsesReasoningStatus
last.OpenAIResponsesReasoningSummary = append([]byte(nil), current.OpenAIResponsesReasoningSummary...)
return
}
if leftSignature == rightSignature {
last.ReasoningSignatureSource = mergeProviderReasoningSignatureSource(last.ReasoningSignatureSource, current.ReasoningSignatureSource)
if strings.TrimSpace(last.OpenAIResponsesReasoningID) == "" {
last.OpenAIResponsesReasoningID = current.OpenAIResponsesReasoningID
}
if strings.TrimSpace(last.OpenAIResponsesReasoningStatus) == "" {
last.OpenAIResponsesReasoningStatus = current.OpenAIResponsesReasoningStatus
}
if len(last.OpenAIResponsesReasoningSummary) == 0 {
last.OpenAIResponsesReasoningSummary = append([]byte(nil), current.OpenAIResponsesReasoningSummary...)
}
}
}
func trimDanglingAssistantToolCalls(input []Message) []Message {
if len(input) == 0 {
return nil
}
trimmed := make([]Message, 0, len(input))
for index := 0; index < len(input); index++ {
message := cloneProviderMessage(input[index])
if strings.TrimSpace(message.Role) != "assistant" || len(message.ToolCalls) == 0 {
trimmed = append(trimmed, message)
continue
}
end := index + 1
responded := make(map[string]struct{}, len(message.ToolCalls))
for end < len(input) && strings.TrimSpace(input[end].Role) == "tool" {
toolCallID := strings.TrimSpace(input[end].ToolCallID)
if toolCallID != "" {
responded[toolCallID] = struct{}{}
}
end++
}
nextToolCalls := make([]ToolCallDescriptor, 0, len(message.ToolCalls))
allowedToolCallIDs := make(map[string]struct{}, len(message.ToolCalls))
for _, toolCall := range message.ToolCalls {
toolCallID := strings.TrimSpace(toolCall.ID)
if _, ok := responded[toolCallID]; !ok {
continue
}
item := toolCall
item.Index = len(nextToolCalls)
nextToolCalls = append(nextToolCalls, item)
allowedToolCallIDs[toolCallID] = struct{}{}
}
if len(nextToolCalls) > 0 {
message.ToolCalls = nextToolCalls
trimmed = append(trimmed, message)
for toolIndex := index + 1; toolIndex < end; toolIndex++ {
toolMessage := cloneProviderMessage(input[toolIndex])
if _, ok := allowedToolCallIDs[strings.TrimSpace(toolMessage.ToolCallID)]; !ok {
continue
}
trimmed = append(trimmed, toolMessage)
}
} else if strings.TrimSpace(message.Content) != "" || len(message.ContentParts) > 0 || strings.TrimSpace(message.ReasoningContent) != "" {
message.ToolCalls = nil
trimmed = append(trimmed, message)
}
index = end - 1
}
return trimmed
}
func normalizeRuntimeThinkingEffort(raw string) string {
switch strings.ToLower(strings.TrimSpace(raw)) {
case "disabled", "low", "medium", "high", "xhigh", "max":
return strings.ToLower(strings.TrimSpace(raw))
case "disable", "off", "none", "false", "no", "0":
return "disabled"
case "very_high", "very-high", "veryhigh", "x-high", "extra_high", "extra-high", "extrahigh":
return "xhigh"
case "maximum":
return "max"
default:
return ""
}
}
func openAIReasoningEffortFromRuntime(runtimeThinkingEffort string) string {
switch normalizeRuntimeThinkingEffort(runtimeThinkingEffort) {
case "low", "medium", "high", "xhigh", "max":
return normalizeRuntimeThinkingEffort(runtimeThinkingEffort)
default:
return ""
}
}