Skip to content

Commit 08a1d2e

Browse files
authored
Merge pull request #390 from NguyenSiTrung/main
feat(amp): add model mapping support for routing unavailable models to alternatives
2 parents 54e2411 + 3409f4e commit 08a1d2e

File tree

11 files changed

+591
-18
lines changed

11 files changed

+591
-18
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ CLIProxyAPI includes integrated support for [Amp CLI](https://ampcode.com) and A
5656
- Provider route aliases for Amp's API patterns (`/api/provider/{provider}/v1...`)
5757
- Management proxy for OAuth authentication and account features
5858
- Smart model fallback with automatic routing
59+
- **Model mapping** to route unavailable models to alternatives (e.g., `claude-opus-4.5``claude-sonnet-4`)
5960
- Security-first design with localhost-only management endpoints
6061

6162
**[Complete Amp CLI Integration Guide](docs/amp-cli-integration.md)**

config.example.yaml

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,28 @@ quota-exceeded:
5555
# When true, enable authentication for the WebSocket API (/v1/ws).
5656
ws-auth: false
5757

58+
# Amp CLI Integration
59+
# Configure upstream URL for Amp CLI OAuth and management features
60+
#amp-upstream-url: "https://ampcode.com"
61+
62+
# Optional: Override API key for Amp upstream (otherwise uses env or file)
63+
#amp-upstream-api-key: ""
64+
65+
# Restrict Amp management routes (/api/auth, /api/user, etc.) to localhost only (recommended)
66+
#amp-restrict-management-to-localhost: true
67+
68+
# Amp Model Mappings
69+
# Route unavailable Amp models to alternative models available in your local proxy.
70+
# Useful when Amp CLI requests models you don't have access to (e.g., Claude Opus 4.5)
71+
# but you have a similar model available (e.g., Claude Sonnet 4).
72+
#amp-model-mappings:
73+
# - from: "claude-opus-4.5" # Model requested by Amp CLI
74+
# to: "claude-sonnet-4" # Route to this available model instead
75+
# - from: "gpt-5"
76+
# to: "gemini-2.5-pro"
77+
# - from: "claude-3-opus-20240229"
78+
# to: "claude-3-5-sonnet-20241022"
79+
5880
# Gemini API keys (preferred)
5981
#gemini-api-key:
6082
# - api-key: "AIzaSy...01"

docs/amp-cli-integration.md

Lines changed: 54 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ This guide explains how to use CLIProxyAPI with Amp CLI and Amp IDE extensions,
88
- [Which Providers Should You Authenticate?](#which-providers-should-you-authenticate)
99
- [Architecture](#architecture)
1010
- [Configuration](#configuration)
11+
- [Model Mapping Configuration](#model-mapping-configuration)
1112
- [Setup](#setup)
1213
- [Usage](#usage)
1314
- [Troubleshooting](#troubleshooting)
@@ -21,6 +22,7 @@ The Amp CLI integration adds specialized routing to support Amp's API patterns w
2122
- **Provider route aliases**: Maps Amp's `/api/provider/{provider}/v1...` patterns to CLIProxyAPI handlers
2223
- **Management proxy**: Forwards OAuth and account management requests to Amp's control plane
2324
- **Smart fallback**: Automatically routes unconfigured models to ampcode.com
25+
- **Model mapping**: Route unavailable models to alternatives you have access to (e.g., `claude-opus-4.5``claude-sonnet-4`)
2426
- **Secret management**: Configurable precedence (config > env > file) with 5-minute caching
2527
- **Security-first**: Management routes restricted to localhost by default
2628
- **Automatic gzip handling**: Decompresses responses from Amp upstream
@@ -75,7 +77,10 @@ Amp CLI/IDE
7577
│ ↓
7678
│ ├─ Model configured locally?
7779
│ │ YES → Use local OAuth tokens (OpenAI/Claude/Gemini handlers)
78-
│ │ NO → Forward to ampcode.com (reverse proxy)
80+
│ │ NO ↓
81+
│ │ ├─ Model mapping configured?
82+
│ │ │ YES → Rewrite model → Use local handler (free)
83+
│ │ │ NO → Forward to ampcode.com (uses Amp credits)
7984
│ ↓
8085
│ Response
8186
@@ -115,6 +120,49 @@ amp-upstream-url: "https://ampcode.com"
115120
amp-restrict-management-to-localhost: true
116121
```
117122
123+
### Model Mapping Configuration
124+
125+
When Amp CLI requests a model that you don't have access to, you can configure mappings to route those requests to alternative models that you DO have available. This avoids consuming Amp credits for models you could handle locally.
126+
127+
```yaml
128+
# Route unavailable models to alternatives
129+
amp-model-mappings:
130+
# Example: Route Claude Opus 4.5 requests to Claude Sonnet 4
131+
- from: "claude-opus-4.5"
132+
to: "claude-sonnet-4"
133+
134+
# Example: Route GPT-5 requests to Gemini 2.5 Pro
135+
- from: "gpt-5"
136+
to: "gemini-2.5-pro"
137+
138+
# Example: Map older model names to newer versions
139+
- from: "claude-3-opus-20240229"
140+
to: "claude-3-5-sonnet-20241022"
141+
```
142+
143+
**How it works:**
144+
145+
1. Amp CLI requests a model (e.g., `claude-opus-4.5`)
146+
2. CLIProxyAPI checks if a local provider is available for that model
147+
3. If not available, it checks the model mappings
148+
4. If a mapping exists, the request is rewritten to use the target model
149+
5. The request is then handled locally (free, using your OAuth subscription)
150+
151+
**Benefits:**
152+
- **Save Amp credits**: Use your local subscriptions instead of forwarding to ampcode.com
153+
- **Hot-reload**: Mappings can be updated without restarting the proxy
154+
- **Structured logging**: Clear logs show when mappings are applied
155+
156+
**Routing Decision Logs:**
157+
158+
The proxy logs each routing decision with structured fields:
159+
160+
```
161+
[AMP] Using local provider for model: gemini-2.5-pro # Local provider (free)
162+
[AMP] Model mapped: claude-opus-4.5 -> claude-sonnet-4 # Mapping applied (free)
163+
[AMP] Forwarding to ampcode.com (uses Amp credits) - model_id: gpt-5 # Fallback (costs credits)
164+
```
165+
118166
### Secret Resolution Precedence
119167

120168
The Amp module resolves API keys using this precedence order:
@@ -301,11 +349,14 @@ When Amp requests a model:
301349

302350
1. **Check local configuration**: Does CLIProxyAPI have OAuth tokens for this model's provider?
303351
2. **If YES**: Route to local handler (use your OAuth subscription)
304-
3. **If NO**: Forward to ampcode.com (use Amp's default routing)
352+
3. **If NO**: Check if a model mapping exists
353+
4. **If mapping exists**: Rewrite request to mapped model → Route to local handler (free)
354+
5. **If no mapping**: Forward to ampcode.com (uses Amp credits)
305355

306356
This enables seamless mixed usage:
307357
- Models you've configured (Gemini, ChatGPT, Claude) → Your OAuth subscriptions
308-
- Models you haven't configured → Amp's default providers
358+
- Models with mappings configured → Routed to alternative local models (free)
359+
- Models you haven't configured and have no mapping → Amp's default providers (uses credits)
309360

310361
### Example API Calls
311362

internal/api/modules/amp/amp.go

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,11 +23,13 @@ type Option func(*AmpModule)
2323
// - Reverse proxy to Amp control plane for OAuth/management
2424
// - Provider-specific route aliases (/api/provider/{provider}/...)
2525
// - Automatic gzip decompression for misconfigured upstreams
26+
// - Model mapping for routing unavailable models to alternatives
2627
type AmpModule struct {
2728
secretSource SecretSource
2829
proxy *httputil.ReverseProxy
2930
accessManager *sdkaccess.Manager
3031
authMiddleware_ gin.HandlerFunc
32+
modelMapper *DefaultModelMapper
3133
enabled bool
3234
registerOnce sync.Once
3335
}
@@ -101,6 +103,9 @@ func (m *AmpModule) Register(ctx modules.Context) error {
101103
// Use registerOnce to ensure routes are only registered once
102104
var regErr error
103105
m.registerOnce.Do(func() {
106+
// Initialize model mapper from config (for routing unavailable models to alternatives)
107+
m.modelMapper = NewModelMapper(ctx.Config.AmpModelMappings)
108+
104109
// Always register provider aliases - these work without an upstream
105110
m.registerProviderAliases(ctx.Engine, ctx.BaseHandler, auth)
106111

@@ -159,8 +164,16 @@ func (m *AmpModule) getAuthMiddleware(ctx modules.Context) gin.HandlerFunc {
159164
// OnConfigUpdated handles configuration updates.
160165
// Currently requires restart for URL changes (could be enhanced for dynamic updates).
161166
func (m *AmpModule) OnConfigUpdated(cfg *config.Config) error {
167+
// Update model mappings (hot-reload supported)
168+
if m.modelMapper != nil {
169+
log.Infof("amp config updated: reloading %d model mapping(s)", len(cfg.AmpModelMappings))
170+
m.modelMapper.UpdateMappings(cfg.AmpModelMappings)
171+
} else {
172+
log.Warnf("amp model mapper not initialized, skipping model mapping update")
173+
}
174+
162175
if !m.enabled {
163-
log.Debug("Amp routing not enabled, skipping config update")
176+
log.Debug("Amp routing not enabled, skipping other config updates")
164177
return nil
165178
}
166179

@@ -181,3 +194,8 @@ func (m *AmpModule) OnConfigUpdated(cfg *config.Config) error {
181194
log.Debug("Amp config updated (restart required for URL changes)")
182195
return nil
183196
}
197+
198+
// GetModelMapper returns the model mapper instance (for testing/debugging).
199+
func (m *AmpModule) GetModelMapper() *DefaultModelMapper {
200+
return m.modelMapper
201+
}

internal/api/modules/amp/fallback_handlers.go

Lines changed: 137 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,16 +6,75 @@ import (
66
"io"
77
"net/http/httputil"
88
"strings"
9+
"time"
910

1011
"github.com/gin-gonic/gin"
1112
"github.com/router-for-me/CLIProxyAPI/v6/internal/util"
1213
log "github.com/sirupsen/logrus"
1314
)
1415

16+
// AmpRouteType represents the type of routing decision made for an Amp request
17+
type AmpRouteType string
18+
19+
const (
20+
// RouteTypeLocalProvider indicates the request is handled by a local OAuth provider (free)
21+
RouteTypeLocalProvider AmpRouteType = "LOCAL_PROVIDER"
22+
// RouteTypeModelMapping indicates the request was remapped to another available model (free)
23+
RouteTypeModelMapping AmpRouteType = "MODEL_MAPPING"
24+
// RouteTypeAmpCredits indicates the request is forwarded to ampcode.com (uses Amp credits)
25+
RouteTypeAmpCredits AmpRouteType = "AMP_CREDITS"
26+
// RouteTypeNoProvider indicates no provider or fallback available
27+
RouteTypeNoProvider AmpRouteType = "NO_PROVIDER"
28+
)
29+
30+
// logAmpRouting logs the routing decision for an Amp request with structured fields
31+
func logAmpRouting(routeType AmpRouteType, requestedModel, resolvedModel, provider, path string) {
32+
fields := log.Fields{
33+
"component": "amp-routing",
34+
"route_type": string(routeType),
35+
"requested_model": requestedModel,
36+
"path": path,
37+
"timestamp": time.Now().Format(time.RFC3339),
38+
}
39+
40+
if resolvedModel != "" && resolvedModel != requestedModel {
41+
fields["resolved_model"] = resolvedModel
42+
}
43+
if provider != "" {
44+
fields["provider"] = provider
45+
}
46+
47+
switch routeType {
48+
case RouteTypeLocalProvider:
49+
fields["cost"] = "free"
50+
fields["source"] = "local_oauth"
51+
log.WithFields(fields).Infof("[AMP] Using local provider for model: %s", requestedModel)
52+
53+
case RouteTypeModelMapping:
54+
fields["cost"] = "free"
55+
fields["source"] = "local_oauth"
56+
fields["mapping"] = requestedModel + " -> " + resolvedModel
57+
log.WithFields(fields).Infof("[AMP] Model mapped: %s -> %s", requestedModel, resolvedModel)
58+
59+
case RouteTypeAmpCredits:
60+
fields["cost"] = "amp_credits"
61+
fields["source"] = "ampcode.com"
62+
fields["model_id"] = requestedModel // Explicit model_id for easy config reference
63+
log.WithFields(fields).Warnf("[AMP] Forwarding to ampcode.com (uses Amp credits) - model_id: %s | To use local proxy, add to config: amp-model-mappings: [{from: \"%s\", to: \"<your-local-model>\"}]", requestedModel, requestedModel)
64+
65+
case RouteTypeNoProvider:
66+
fields["cost"] = "none"
67+
fields["source"] = "error"
68+
fields["model_id"] = requestedModel // Explicit model_id for easy config reference
69+
log.WithFields(fields).Warnf("[AMP] No provider available for model_id: %s", requestedModel)
70+
}
71+
}
72+
1573
// FallbackHandler wraps a standard handler with fallback logic to ampcode.com
1674
// when the model's provider is not available in CLIProxyAPI
1775
type FallbackHandler struct {
18-
getProxy func() *httputil.ReverseProxy
76+
getProxy func() *httputil.ReverseProxy
77+
modelMapper ModelMapper
1978
}
2079

2180
// NewFallbackHandler creates a new fallback handler wrapper
@@ -26,10 +85,25 @@ func NewFallbackHandler(getProxy func() *httputil.ReverseProxy) *FallbackHandler
2685
}
2786
}
2887

88+
// NewFallbackHandlerWithMapper creates a new fallback handler with model mapping support
89+
func NewFallbackHandlerWithMapper(getProxy func() *httputil.ReverseProxy, mapper ModelMapper) *FallbackHandler {
90+
return &FallbackHandler{
91+
getProxy: getProxy,
92+
modelMapper: mapper,
93+
}
94+
}
95+
96+
// SetModelMapper sets the model mapper for this handler (allows late binding)
97+
func (fh *FallbackHandler) SetModelMapper(mapper ModelMapper) {
98+
fh.modelMapper = mapper
99+
}
100+
29101
// WrapHandler wraps a gin.HandlerFunc with fallback logic
30102
// If the model's provider is not configured in CLIProxyAPI, it forwards to ampcode.com
31103
func (fh *FallbackHandler) WrapHandler(handler gin.HandlerFunc) gin.HandlerFunc {
32104
return func(c *gin.Context) {
105+
requestPath := c.Request.URL.Path
106+
33107
// Read the request body to extract the model name
34108
bodyBytes, err := io.ReadAll(c.Request.Body)
35109
if err != nil {
@@ -55,12 +129,33 @@ func (fh *FallbackHandler) WrapHandler(handler gin.HandlerFunc) gin.HandlerFunc
55129
// Check if we have providers for this model
56130
providers := util.GetProviderName(normalizedModel)
57131

132+
// Track resolved model for logging (may change if mapping is applied)
133+
resolvedModel := normalizedModel
134+
usedMapping := false
135+
58136
if len(providers) == 0 {
59-
// No providers configured - check if we have a proxy for fallback
137+
// No providers configured - check if we have a model mapping
138+
if fh.modelMapper != nil {
139+
if mappedModel := fh.modelMapper.MapModel(normalizedModel); mappedModel != "" {
140+
// Mapping found - rewrite the model in request body
141+
bodyBytes = rewriteModelInBody(bodyBytes, mappedModel)
142+
c.Request.Body = io.NopCloser(bytes.NewReader(bodyBytes))
143+
resolvedModel = mappedModel
144+
usedMapping = true
145+
146+
// Get providers for the mapped model
147+
providers = util.GetProviderName(mappedModel)
148+
149+
// Continue to handler with remapped model
150+
goto handleRequest
151+
}
152+
}
153+
154+
// No mapping found - check if we have a proxy for fallback
60155
proxy := fh.getProxy()
61156
if proxy != nil {
62-
// Fallback to ampcode.com
63-
log.Infof("amp fallback: model %s has no configured provider, forwarding to ampcode.com", modelName)
157+
// Log: Forwarding to ampcode.com (uses Amp credits)
158+
logAmpRouting(RouteTypeAmpCredits, modelName, "", "", requestPath)
64159

65160
// Restore body again for the proxy
66161
c.Request.Body = io.NopCloser(bytes.NewReader(bodyBytes))
@@ -71,7 +166,23 @@ func (fh *FallbackHandler) WrapHandler(handler gin.HandlerFunc) gin.HandlerFunc
71166
}
72167

73168
// No proxy available, let the normal handler return the error
74-
log.Debugf("amp fallback: model %s has no configured provider and no proxy available", modelName)
169+
logAmpRouting(RouteTypeNoProvider, modelName, "", "", requestPath)
170+
}
171+
172+
handleRequest:
173+
174+
// Log the routing decision
175+
providerName := ""
176+
if len(providers) > 0 {
177+
providerName = providers[0]
178+
}
179+
180+
if usedMapping {
181+
// Log: Model was mapped to another model
182+
logAmpRouting(RouteTypeModelMapping, modelName, resolvedModel, providerName, requestPath)
183+
} else if len(providers) > 0 {
184+
// Log: Using local provider (free)
185+
logAmpRouting(RouteTypeLocalProvider, modelName, resolvedModel, providerName, requestPath)
75186
}
76187

77188
// Providers available or no proxy for fallback, restore body and use normal handler
@@ -91,6 +202,27 @@ func (fh *FallbackHandler) WrapHandler(handler gin.HandlerFunc) gin.HandlerFunc
91202
}
92203
}
93204

205+
// rewriteModelInBody replaces the model name in a JSON request body
206+
func rewriteModelInBody(body []byte, newModel string) []byte {
207+
var payload map[string]interface{}
208+
if err := json.Unmarshal(body, &payload); err != nil {
209+
log.Warnf("amp model mapping: failed to parse body for rewrite: %v", err)
210+
return body
211+
}
212+
213+
if _, exists := payload["model"]; exists {
214+
payload["model"] = newModel
215+
newBody, err := json.Marshal(payload)
216+
if err != nil {
217+
log.Warnf("amp model mapping: failed to marshal rewritten body: %v", err)
218+
return body
219+
}
220+
return newBody
221+
}
222+
223+
return body
224+
}
225+
94226
// extractModelFromRequest attempts to extract the model name from various request formats
95227
func extractModelFromRequest(body []byte, c *gin.Context) string {
96228
// First try to parse from JSON body (OpenAI, Claude, etc.)

0 commit comments

Comments
 (0)