feat(routing): implement unified model routing with OAuth and API key providers

- Added a new routing package to manage provider registration and model resolution.
- Introduced Router, Executor, and Provider interfaces to handle different provider types.
- Implemented OAuthProvider and APIKeyProvider to support OAuth and API key authentication.
- Enhanced DefaultModelMapper to include OAuth model alias handling and fallback mechanisms.
- Updated context management in API handlers to preserve fallback models.
- Added tests for routing logic and provider selection.
- Enhanced Claude request conversion to handle reasoning content based on thinking mode.
This commit is contained in:
이대희
2026-01-30 21:29:05 +09:00
committed by hkfires
parent 09044e8ccc
commit 89907231c1
12 changed files with 1041 additions and 55 deletions

View File

@@ -0,0 +1,39 @@
// Package routing provides adapter to integrate with existing codebase.
package routing
import (
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
)
// Adapter bridges the new routing layer with existing auth manager.
type Adapter struct {
router *Router
exec *Executor
}
// NewAdapter creates a new adapter with the given configuration and auth manager.
func NewAdapter(cfg *config.Config, authManager *coreauth.Manager) *Adapter {
registry := NewRegistry()
// TODO: Register OAuth providers from authManager
// TODO: Register API key providers from cfg
router := NewRouter(registry, cfg)
exec := NewExecutor(router)
return &Adapter{
router: router,
exec: exec,
}
}
// Router returns the underlying router.
func (a *Adapter) Router() *Router {
return a.router
}
// Executor returns the underlying executor.
func (a *Adapter) Executor() *Executor {
return a.exec
}

View File

@@ -0,0 +1,11 @@
package ctxkeys
type key string
const (
MappedModel key = "mapped_model"
FallbackModels key = "fallback_models"
RouteCandidates key = "route_candidates"
RoutingDecision key = "routing_decision"
MappingApplied key = "mapping_applied"
)

View File

@@ -0,0 +1,111 @@
package routing
import (
"context"
"errors"
"github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor"
log "github.com/sirupsen/logrus"
)
// Executor handles request execution with fallback support.
type Executor struct {
router *Router
}
// NewExecutor creates a new executor with the given router.
func NewExecutor(router *Router) *Executor {
return &Executor{router: router}
}
// Execute sends the request through the routing decision.
func (e *Executor) Execute(ctx context.Context, req executor.Request) (executor.Response, error) {
decision := e.router.Resolve(req.Model)
log.Debugf("routing: %s -> %s (%d candidates)",
decision.RequestedModel,
decision.ResolvedModel,
len(decision.Candidates))
var lastErr error
tried := make(map[string]struct{})
for i, candidate := range decision.Candidates {
key := candidate.Provider.Name() + "/" + candidate.Model
if _, ok := tried[key]; ok {
continue
}
tried[key] = struct{}{}
log.Debugf("routing: trying candidate %d/%d: %s with model %s",
i+1, len(decision.Candidates), candidate.Provider.Name(), candidate.Model)
req.Model = candidate.Model
resp, err := candidate.Provider.Execute(ctx, candidate.Model, req)
if err == nil {
return resp, nil
}
lastErr = err
log.Debugf("routing: candidate failed: %v", err)
// Check if it's a fatal error (not retryable)
if isFatalError(err) {
break
}
}
if lastErr != nil {
return executor.Response{}, lastErr
}
return executor.Response{}, errors.New("no available providers")
}
// ExecuteStream sends a streaming request through the routing decision.
func (e *Executor) ExecuteStream(ctx context.Context, req executor.Request) (<-chan executor.StreamChunk, error) {
decision := e.router.Resolve(req.Model)
log.Debugf("routing stream: %s -> %s (%d candidates)",
decision.RequestedModel,
decision.ResolvedModel,
len(decision.Candidates))
var lastErr error
tried := make(map[string]struct{})
for i, candidate := range decision.Candidates {
key := candidate.Provider.Name() + "/" + candidate.Model
if _, ok := tried[key]; ok {
continue
}
tried[key] = struct{}{}
log.Debugf("routing stream: trying candidate %d/%d: %s with model %s",
i+1, len(decision.Candidates), candidate.Provider.Name(), candidate.Model)
req.Model = candidate.Model
chunks, err := candidate.Provider.ExecuteStream(ctx, candidate.Model, req)
if err == nil {
return chunks, nil
}
lastErr = err
log.Debugf("routing stream: candidate failed: %v", err)
if isFatalError(err) {
break
}
}
if lastErr != nil {
return nil, lastErr
}
return nil, errors.New("no available providers")
}
// isFatalError returns true if the error is not retryable.
func isFatalError(err error) bool {
// TODO: implement based on error type
// For now, all errors are retryable
return false
}

View File

@@ -0,0 +1,80 @@
// Package routing provides unified model routing for all provider types.
package routing
import (
"context"
"github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor"
)
// ProviderType indicates the type of provider.
type ProviderType string
const (
ProviderTypeOAuth ProviderType = "oauth"
ProviderTypeAPIKey ProviderType = "api_key"
ProviderTypeVertex ProviderType = "vertex"
)
// Provider is the unified interface for all provider types (OAuth, API key, etc.).
type Provider interface {
// Name returns the unique provider identifier.
Name() string
// Type returns the provider type.
Type() ProviderType
// SupportsModel returns true if this provider can handle the given model.
SupportsModel(model string) bool
// Available returns true if the provider is available for the model (not quota exceeded).
Available(model string) bool
// Priority returns the priority for this provider (lower = tried first).
Priority() int
// Execute sends the request to the provider.
Execute(ctx context.Context, model string, req executor.Request) (executor.Response, error)
// ExecuteStream sends a streaming request to the provider.
ExecuteStream(ctx context.Context, model string, req executor.Request) (<-chan executor.StreamChunk, error)
}
// ProviderCandidate represents a provider + model combination to try.
type ProviderCandidate struct {
Provider Provider
Model string // The actual model name to use (may be different from requested due to aliasing)
}
// Registry manages all available providers.
type Registry struct {
providers []Provider
}
// NewRegistry creates a new provider registry.
func NewRegistry() *Registry {
return &Registry{
providers: make([]Provider, 0),
}
}
// Register adds a provider to the registry.
func (r *Registry) Register(p Provider) {
r.providers = append(r.providers, p)
}
// FindProviders returns all providers that support the given model and are available.
func (r *Registry) FindProviders(model string) []Provider {
var result []Provider
for _, p := range r.providers {
if p.SupportsModel(model) && p.Available(model) {
result = append(result, p)
}
}
return result
}
// All returns all registered providers.
func (r *Registry) All() []Provider {
return r.providers
}

View File

@@ -0,0 +1,156 @@
package providers
import (
"context"
"errors"
"net/http"
"strings"
"sync"
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
"github.com/router-for-me/CLIProxyAPI/v6/internal/routing"
"github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor"
)
// APIKeyProvider wraps API key configs as routing.Provider.
type APIKeyProvider struct {
name string
provider string // claude, gemini, codex, vertex
keys []APIKeyEntry
mu sync.RWMutex
client HTTPClient
}
// APIKeyEntry represents a single API key configuration.
type APIKeyEntry struct {
APIKey string
BaseURL string
Models []config.ClaudeModel // Using ClaudeModel as generic model alias
}
// HTTPClient interface for making HTTP requests.
type HTTPClient interface {
Do(req *http.Request) (*http.Response, error)
}
// NewAPIKeyProvider creates a new API key provider.
func NewAPIKeyProvider(name, provider string, client HTTPClient) *APIKeyProvider {
return &APIKeyProvider{
name: name,
provider: provider,
keys: make([]APIKeyEntry, 0),
client: client,
}
}
// Name returns the provider name.
func (p *APIKeyProvider) Name() string {
return p.name
}
// Type returns ProviderTypeAPIKey.
func (p *APIKeyProvider) Type() routing.ProviderType {
return routing.ProviderTypeAPIKey
}
// SupportsModel checks if the model is supported by this provider.
func (p *APIKeyProvider) SupportsModel(model string) bool {
p.mu.RLock()
defer p.mu.RUnlock()
for _, key := range p.keys {
for _, m := range key.Models {
if strings.EqualFold(m.Alias, model) || strings.EqualFold(m.Name, model) {
return true
}
}
}
return false
}
// Available always returns true for API keys (unless explicitly disabled).
func (p *APIKeyProvider) Available(model string) bool {
return p.SupportsModel(model)
}
// Priority returns the priority (API key is lower priority than OAuth).
func (p *APIKeyProvider) Priority() int {
return 20
}
// Execute sends the request using the API key.
func (p *APIKeyProvider) Execute(ctx context.Context, model string, req executor.Request) (executor.Response, error) {
key := p.selectKey(model)
if key == nil {
return executor.Response{}, ErrNoMatchingAPIKey
}
// Resolve the actual model name from alias
actualModel := p.resolveModel(key, model)
// Execute via HTTP client
return p.executeHTTP(ctx, key, actualModel, req)
}
// ExecuteStream sends a streaming request.
func (p *APIKeyProvider) ExecuteStream(ctx context.Context, model string, req executor.Request) (
<-chan executor.StreamChunk, error) {
key := p.selectKey(model)
if key == nil {
return nil, ErrNoMatchingAPIKey
}
actualModel := p.resolveModel(key, model)
return p.executeHTTPStream(ctx, key, actualModel, req)
}
// AddKey adds an API key entry.
func (p *APIKeyProvider) AddKey(entry APIKeyEntry) {
p.mu.Lock()
defer p.mu.Unlock()
p.keys = append(p.keys, entry)
}
// selectKey selects a key that supports the model.
func (p *APIKeyProvider) selectKey(model string) *APIKeyEntry {
p.mu.RLock()
defer p.mu.RUnlock()
for _, key := range p.keys {
for _, m := range key.Models {
if strings.EqualFold(m.Alias, model) || strings.EqualFold(m.Name, model) {
return &key
}
}
}
return nil
}
// resolveModel resolves alias to actual model name.
func (p *APIKeyProvider) resolveModel(key *APIKeyEntry, requested string) string {
for _, m := range key.Models {
if strings.EqualFold(m.Alias, requested) {
return m.Name
}
}
return requested
}
// executeHTTP makes the HTTP request.
func (p *APIKeyProvider) executeHTTP(ctx context.Context, key *APIKeyEntry, model string, req executor.Request) (executor.Response, error) {
// TODO: implement actual HTTP execution
// This is a placeholder - actual implementation would build HTTP request
return executor.Response{}, errors.New("not yet implemented")
}
// executeHTTPStream makes a streaming HTTP request.
func (p *APIKeyProvider) executeHTTPStream(ctx context.Context, key *APIKeyEntry, model string, req executor.Request) (
<-chan executor.StreamChunk, error) {
// TODO: implement actual HTTP streaming
return nil, errors.New("not yet implemented")
}
// Errors
var (
ErrNoMatchingAPIKey = errors.New("no API key supports the requested model")
)

View File

@@ -0,0 +1,132 @@
package providers
import (
"context"
"errors"
"sync"
"github.com/router-for-me/CLIProxyAPI/v6/internal/routing"
coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
"github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor"
)
// OAuthProvider wraps OAuth-based auths as routing.Provider.
type OAuthProvider struct {
name string
auths []*coreauth.Auth
mu sync.RWMutex
executor coreauth.ProviderExecutor
}
// NewOAuthProvider creates a new OAuth provider.
func NewOAuthProvider(name string, exec coreauth.ProviderExecutor) *OAuthProvider {
return &OAuthProvider{
name: name,
auths: make([]*coreauth.Auth, 0),
executor: exec,
}
}
// Name returns the provider name.
func (p *OAuthProvider) Name() string {
return p.name
}
// Type returns ProviderTypeOAuth.
func (p *OAuthProvider) Type() routing.ProviderType {
return routing.ProviderTypeOAuth
}
// SupportsModel checks if any auth supports the model.
func (p *OAuthProvider) SupportsModel(model string) bool {
p.mu.RLock()
defer p.mu.RUnlock()
// OAuth providers typically support models via oauth-model-alias
// The actual model support is determined at execution time
return true
}
// Available checks if there's an available auth for the model.
func (p *OAuthProvider) Available(model string) bool {
p.mu.RLock()
defer p.mu.RUnlock()
for _, auth := range p.auths {
if p.isAuthAvailable(auth, model) {
return true
}
}
return false
}
// Priority returns the priority (OAuth is preferred over API key).
func (p *OAuthProvider) Priority() int {
return 10
}
// Execute sends the request using an available OAuth auth.
func (p *OAuthProvider) Execute(ctx context.Context, model string, req executor.Request) (executor.Response, error) {
auth := p.selectAuth(model)
if auth == nil {
return executor.Response{}, ErrNoAvailableAuth
}
return p.executor.Execute(ctx, auth, req, executor.Options{})
}
// ExecuteStream sends a streaming request.
func (p *OAuthProvider) ExecuteStream(ctx context.Context, model string, req executor.Request) (<-chan executor.StreamChunk, error) {
auth := p.selectAuth(model)
if auth == nil {
return nil, ErrNoAvailableAuth
}
return p.executor.ExecuteStream(ctx, auth, req, executor.Options{})
}
// AddAuth adds an auth to this provider.
func (p *OAuthProvider) AddAuth(auth *coreauth.Auth) {
p.mu.Lock()
defer p.mu.Unlock()
p.auths = append(p.auths, auth)
}
// RemoveAuth removes an auth from this provider.
func (p *OAuthProvider) RemoveAuth(authID string) {
p.mu.Lock()
defer p.mu.Unlock()
filtered := make([]*coreauth.Auth, 0, len(p.auths))
for _, auth := range p.auths {
if auth.ID != authID {
filtered = append(filtered, auth)
}
}
p.auths = filtered
}
// isAuthAvailable checks if an auth is available for the model.
func (p *OAuthProvider) isAuthAvailable(auth *coreauth.Auth, model string) bool {
// TODO: integrate with model_registry for quota checking
// For now, just check if auth exists
return auth != nil
}
// selectAuth selects an available auth for the model.
func (p *OAuthProvider) selectAuth(model string) *coreauth.Auth {
p.mu.RLock()
defer p.mu.RUnlock()
for _, auth := range p.auths {
if p.isAuthAvailable(auth, model) {
return auth
}
}
return nil
}
// Errors
var (
ErrNoAvailableAuth = errors.New("no available OAuth auth for model")
)

127
internal/routing/router.go Normal file
View File

@@ -0,0 +1,127 @@
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
}
// RoutingDecision contains the resolved routing information.
type RoutingDecision 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.
func (r *Router) Resolve(requestedModel string) *RoutingDecision {
// 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 &RoutingDecision{
RequestedModel: requestedModel,
ResolvedModel: targetModel,
Candidates: candidates,
}
}
// 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)
}
}
}
}

View File

@@ -0,0 +1,115 @@
package routing
import (
"context"
"testing"
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
"github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor"
"github.com/stretchr/testify/assert"
)
// mockProvider is a test double for Provider.
type mockProvider struct {
name string
providerType ProviderType
supportsModels map[string]bool
available bool
priority int
}
func (m *mockProvider) Name() string { return m.name }
func (m *mockProvider) Type() ProviderType { return m.providerType }
func (m *mockProvider) SupportsModel(model string) bool { return m.supportsModels[model] }
func (m *mockProvider) Available(model string) bool { return m.available }
func (m *mockProvider) Priority() int { return m.priority }
func (m *mockProvider) Execute(ctx context.Context, model string, req executor.Request) (executor.Response, error) {
return executor.Response{}, nil
}
func (m *mockProvider) ExecuteStream(ctx context.Context, model string, req executor.Request) (<-chan executor.StreamChunk, error) {
return nil, nil
}
func TestRouter_Resolve_ModelMappings(t *testing.T) {
registry := NewRegistry()
// Add a provider
p := &mockProvider{
name: "test-provider",
providerType: ProviderTypeOAuth,
supportsModels: map[string]bool{"target-model": true},
available: true,
priority: 1,
}
registry.Register(p)
// Create router with model mapping
cfg := &config.Config{
AmpCode: config.AmpCode{
ModelMappings: []config.AmpModelMapping{
{From: "user-model", To: "target-model"},
},
},
}
router := NewRouter(registry, cfg)
// Resolve
decision := router.Resolve("user-model")
assert.Equal(t, "user-model", decision.RequestedModel)
assert.Equal(t, "target-model", decision.ResolvedModel)
assert.Len(t, decision.Candidates, 1)
assert.Equal(t, "target-model", decision.Candidates[0].Model)
}
func TestRouter_Resolve_OAuthAliases(t *testing.T) {
registry := NewRegistry()
// Add providers
p1 := &mockProvider{
name: "oauth-1",
providerType: ProviderTypeOAuth,
supportsModels: map[string]bool{"primary-model": true},
available: true,
priority: 1,
}
p2 := &mockProvider{
name: "oauth-2",
providerType: ProviderTypeOAuth,
supportsModels: map[string]bool{"fallback-model": true},
available: true,
priority: 2,
}
registry.Register(p1)
registry.Register(p2)
// Create router with oauth aliases
cfg := &config.Config{
OAuthModelAlias: map[string][]config.OAuthModelAlias{
"test-channel": {
{Name: "primary-model", Alias: "fallback-model"},
},
},
}
router := NewRouter(registry, cfg)
// Resolve
decision := router.Resolve("primary-model")
assert.Equal(t, "primary-model", decision.ResolvedModel)
assert.Len(t, decision.Candidates, 2)
// Primary should come first (lower priority value)
assert.Equal(t, "primary-model", decision.Candidates[0].Model)
assert.Equal(t, "fallback-model", decision.Candidates[1].Model)
}
func TestRouter_Resolve_NoProviders(t *testing.T) {
registry := NewRegistry()
cfg := &config.Config{}
router := NewRouter(registry, cfg)
decision := router.Resolve("unknown-model")
assert.Equal(t, "unknown-model", decision.ResolvedModel)
assert.Empty(t, decision.Candidates)
}