mirror of
https://wget.la/https://github.com/leookun/cursor-byok
synced 2026-08-18 12:07:02 +08:00
v0.3.8
This commit is contained in:
@@ -0,0 +1,419 @@
|
||||
// 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(last.ToolCalls) == 0 || 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 ""
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user