mirror of
https://wget.la/https://github.com/leookun/cursor-byok
synced 2026-08-17 03:27:02 +08:00
- 修复内存泄漏问题,该可能导致内存异常占用 - 支持俄语增加翻译范围 - 修复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标准接口拉取。
420 lines
14 KiB
Go
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 ""
|
|
}
|
|
}
|