mirror of
https://github.com/router-for-me/CLIProxyAPI.git
synced 2026-02-02 04:20:50 +08:00
Migrates the AMP module to a new unified routing system, replacing the fallback handler with a router-based approach. This change introduces a `ModelRoutingWrapper` that handles model extraction, routing decisions, and proxying based on provider availability and model mappings. It provides a more flexible and maintainable routing mechanism by centralizing routing logic. The changes include: - Introducing new `routing` package with core routing logic. - Creating characterization tests to capture existing behavior. - Implementing model extraction and rewriting. - Updating AMP module routes to utilize the new routing wrapper. - Deprecating `FallbackHandler` in favor of the new `ModelRoutingWrapper`.
268 lines
8.4 KiB
Go
268 lines
8.4 KiB
Go
package routing
|
|
|
|
import (
|
|
"sort"
|
|
"strings"
|
|
|
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/thinking"
|
|
)
|
|
|
|
// Router resolves models to provider candidates.
|
|
type Router struct {
|
|
registry *Registry
|
|
modelMappings map[string]string // normalized from -> to
|
|
oauthAliases map[string][]string // normalized model -> []alias
|
|
}
|
|
|
|
// NewRouter creates a new router with the given configuration.
|
|
func NewRouter(registry *Registry, cfg *config.Config) *Router {
|
|
r := &Router{
|
|
registry: registry,
|
|
modelMappings: make(map[string]string),
|
|
oauthAliases: make(map[string][]string),
|
|
}
|
|
|
|
if cfg != nil {
|
|
r.loadModelMappings(cfg.AmpCode.ModelMappings)
|
|
r.loadOAuthAliases(cfg.OAuthModelAlias)
|
|
}
|
|
|
|
return r
|
|
}
|
|
|
|
// LegacyRoutingDecision contains the resolved routing information.
|
|
// Deprecated: Will be replaced by RoutingDecision from types.go in T-013.
|
|
type LegacyRoutingDecision struct {
|
|
RequestedModel string // Original model from request
|
|
ResolvedModel string // After model-mappings
|
|
Candidates []ProviderCandidate // Ordered list of providers to try
|
|
}
|
|
|
|
// Resolve determines the routing decision for the requested model.
|
|
// Deprecated: Will be updated to use RoutingRequest and return *RoutingDecision in T-013.
|
|
func (r *Router) Resolve(requestedModel string) *LegacyRoutingDecision {
|
|
// 1. Extract thinking suffix
|
|
suffixResult := thinking.ParseSuffix(requestedModel)
|
|
baseModel := suffixResult.ModelName
|
|
|
|
// 2. Apply model-mappings
|
|
targetModel := r.applyMappings(baseModel)
|
|
|
|
// 3. Find primary providers
|
|
candidates := r.findCandidates(targetModel, suffixResult)
|
|
|
|
// 4. Add fallback aliases
|
|
for _, alias := range r.oauthAliases[strings.ToLower(targetModel)] {
|
|
candidates = append(candidates, r.findCandidates(alias, suffixResult)...)
|
|
}
|
|
|
|
// 5. Sort by priority
|
|
sort.Slice(candidates, func(i, j int) bool {
|
|
return candidates[i].Provider.Priority() < candidates[j].Provider.Priority()
|
|
})
|
|
|
|
return &LegacyRoutingDecision{
|
|
RequestedModel: requestedModel,
|
|
ResolvedModel: targetModel,
|
|
Candidates: candidates,
|
|
}
|
|
}
|
|
|
|
// ResolveV2 determines the routing decision for a routing request.
|
|
// It uses the new RoutingRequest and RoutingDecision types.
|
|
func (r *Router) ResolveV2(req RoutingRequest) *RoutingDecision {
|
|
// 1. Extract thinking suffix
|
|
suffixResult := thinking.ParseSuffix(req.RequestedModel)
|
|
baseModel := suffixResult.ModelName
|
|
thinkingSuffix := ""
|
|
if suffixResult.HasSuffix {
|
|
thinkingSuffix = "(" + suffixResult.RawSuffix + ")"
|
|
}
|
|
|
|
// 2. Check for local providers
|
|
localCandidates := r.findLocalCandidates(baseModel, suffixResult)
|
|
|
|
// 3. Apply model-mappings if needed
|
|
mappedModel := r.applyMappings(baseModel)
|
|
mappingCandidates := r.findLocalCandidates(mappedModel, suffixResult)
|
|
|
|
// 4. Determine route type based on preferences and availability
|
|
var decision *RoutingDecision
|
|
|
|
if req.ForceModelMapping && mappedModel != baseModel && len(mappingCandidates) > 0 {
|
|
// FORCE MODE: Use mapping even if local provider exists
|
|
decision = r.buildMappingDecision(req.RequestedModel, mappedModel, mappingCandidates, thinkingSuffix, mappingCandidates[1:])
|
|
} else if req.PreferLocalProvider && len(localCandidates) > 0 {
|
|
// DEFAULT MODE with local preference: Use local provider first
|
|
decision = r.buildLocalProviderDecision(req.RequestedModel, localCandidates, thinkingSuffix)
|
|
} else if len(localCandidates) > 0 {
|
|
// DEFAULT MODE: Local provider available
|
|
decision = r.buildLocalProviderDecision(req.RequestedModel, localCandidates, thinkingSuffix)
|
|
} else if mappedModel != baseModel && len(mappingCandidates) > 0 {
|
|
// DEFAULT MODE: No local provider, but mapping available
|
|
decision = r.buildMappingDecision(req.RequestedModel, mappedModel, mappingCandidates, thinkingSuffix, mappingCandidates[1:])
|
|
} else {
|
|
// No local provider, no mapping - use amp credits proxy
|
|
decision = &RoutingDecision{
|
|
RouteType: RouteTypeAmpCredits,
|
|
ResolvedModel: req.RequestedModel,
|
|
ShouldProxy: true,
|
|
}
|
|
}
|
|
|
|
return decision
|
|
}
|
|
|
|
// findLocalCandidates finds local provider candidates for a model.
|
|
func (r *Router) findLocalCandidates(model string, suffixResult thinking.SuffixResult) []ProviderCandidate {
|
|
var candidates []ProviderCandidate
|
|
|
|
for _, p := range r.registry.All() {
|
|
if !p.SupportsModel(model) {
|
|
continue
|
|
}
|
|
|
|
// Apply thinking suffix if needed
|
|
actualModel := model
|
|
if suffixResult.HasSuffix && !thinking.ParseSuffix(model).HasSuffix {
|
|
actualModel = model + "(" + suffixResult.RawSuffix + ")"
|
|
}
|
|
|
|
if p.Available(actualModel) {
|
|
candidates = append(candidates, ProviderCandidate{
|
|
Provider: p,
|
|
Model: actualModel,
|
|
})
|
|
}
|
|
}
|
|
|
|
// Sort by priority
|
|
sort.Slice(candidates, func(i, j int) bool {
|
|
return candidates[i].Provider.Priority() < candidates[j].Provider.Priority()
|
|
})
|
|
|
|
return candidates
|
|
}
|
|
|
|
// buildLocalProviderDecision creates a decision for local provider routing.
|
|
func (r *Router) buildLocalProviderDecision(requestedModel string, candidates []ProviderCandidate, thinkingSuffix string) *RoutingDecision {
|
|
resolvedModel := requestedModel
|
|
if thinkingSuffix != "" {
|
|
// Ensure thinking suffix is preserved
|
|
sr := thinking.ParseSuffix(requestedModel)
|
|
if !sr.HasSuffix {
|
|
resolvedModel = requestedModel + thinkingSuffix
|
|
}
|
|
}
|
|
|
|
var fallbackModels []string
|
|
if len(candidates) > 1 {
|
|
for _, c := range candidates[1:] {
|
|
fallbackModels = append(fallbackModels, c.Model)
|
|
}
|
|
}
|
|
|
|
return &RoutingDecision{
|
|
RouteType: RouteTypeLocalProvider,
|
|
ResolvedModel: resolvedModel,
|
|
ProviderName: candidates[0].Provider.Name(),
|
|
FallbackModels: fallbackModels,
|
|
ShouldProxy: false,
|
|
}
|
|
}
|
|
|
|
// buildMappingDecision creates a decision for model mapping routing.
|
|
func (r *Router) buildMappingDecision(requestedModel, mappedModel string, candidates []ProviderCandidate, thinkingSuffix string, fallbackCandidates []ProviderCandidate) *RoutingDecision {
|
|
// Apply thinking suffix to resolved model if needed
|
|
resolvedModel := mappedModel
|
|
if thinkingSuffix != "" {
|
|
sr := thinking.ParseSuffix(mappedModel)
|
|
if !sr.HasSuffix {
|
|
resolvedModel = mappedModel + thinkingSuffix
|
|
}
|
|
}
|
|
|
|
var fallbackModels []string
|
|
for _, c := range fallbackCandidates {
|
|
fallbackModels = append(fallbackModels, c.Model)
|
|
}
|
|
|
|
// Also add oauth aliases as fallbacks
|
|
baseMapped := thinking.ParseSuffix(mappedModel).ModelName
|
|
for _, alias := range r.oauthAliases[strings.ToLower(baseMapped)] {
|
|
// Check if this alias has providers
|
|
aliasCandidates := r.findLocalCandidates(alias, thinking.SuffixResult{ModelName: alias})
|
|
for _, c := range aliasCandidates {
|
|
fallbackModels = append(fallbackModels, c.Model)
|
|
}
|
|
}
|
|
|
|
return &RoutingDecision{
|
|
RouteType: RouteTypeModelMapping,
|
|
ResolvedModel: resolvedModel,
|
|
ProviderName: candidates[0].Provider.Name(),
|
|
FallbackModels: fallbackModels,
|
|
ShouldProxy: false,
|
|
}
|
|
}
|
|
|
|
// applyMappings applies model-mappings configuration.
|
|
func (r *Router) applyMappings(model string) string {
|
|
key := strings.ToLower(strings.TrimSpace(model))
|
|
if mapped, ok := r.modelMappings[key]; ok {
|
|
return mapped
|
|
}
|
|
return model
|
|
}
|
|
|
|
// findCandidates finds all provider candidates for a model.
|
|
func (r *Router) findCandidates(model string, suffixResult thinking.SuffixResult) []ProviderCandidate {
|
|
var candidates []ProviderCandidate
|
|
|
|
for _, p := range r.registry.All() {
|
|
if !p.SupportsModel(model) {
|
|
continue
|
|
}
|
|
|
|
// Apply thinking suffix if needed
|
|
actualModel := model
|
|
if suffixResult.HasSuffix && !thinking.ParseSuffix(model).HasSuffix {
|
|
actualModel = model + "(" + suffixResult.RawSuffix + ")"
|
|
}
|
|
|
|
if p.Available(actualModel) {
|
|
candidates = append(candidates, ProviderCandidate{
|
|
Provider: p,
|
|
Model: actualModel,
|
|
})
|
|
}
|
|
}
|
|
|
|
return candidates
|
|
}
|
|
|
|
// loadModelMappings loads model-mappings from config.
|
|
func (r *Router) loadModelMappings(mappings []config.AmpModelMapping) {
|
|
for _, m := range mappings {
|
|
from := strings.ToLower(strings.TrimSpace(m.From))
|
|
to := strings.TrimSpace(m.To)
|
|
if from != "" && to != "" {
|
|
r.modelMappings[from] = to
|
|
}
|
|
}
|
|
}
|
|
|
|
// loadOAuthAliases loads oauth-model-alias from config.
|
|
func (r *Router) loadOAuthAliases(aliases map[string][]config.OAuthModelAlias) {
|
|
for _, entries := range aliases {
|
|
for _, entry := range entries {
|
|
name := strings.ToLower(strings.TrimSpace(entry.Name))
|
|
alias := strings.TrimSpace(entry.Alias)
|
|
if name != "" && alias != "" && name != alias {
|
|
r.oauthAliases[name] = append(r.oauthAliases[name], alias)
|
|
}
|
|
}
|
|
}
|
|
}
|