Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
de77903915 | ||
|
|
56ed0d8d90 | ||
|
|
42e818ce05 | ||
|
|
2d4c54ba54 | ||
|
|
e9eb4db8bb | ||
|
|
d26ed069fa | ||
|
|
afcab5efda | ||
|
|
6cf1d8a947 | ||
|
|
a174d015f2 | ||
|
|
9c09128e00 | ||
|
|
549c0c2c5a | ||
|
|
f092801b61 | ||
|
|
1b638b3629 | ||
|
|
6f5f81753d | ||
|
|
76af454034 | ||
|
|
e54d2f6b2a | ||
|
|
bfc738b76a | ||
|
|
396899a530 | ||
|
|
edc654edf9 | ||
|
|
08586334af |
@@ -134,6 +134,8 @@ ws-auth: false
|
|||||||
# upstream-api-key: ""
|
# upstream-api-key: ""
|
||||||
# # Restrict Amp management routes (/api/auth, /api/user, etc.) to localhost only (recommended)
|
# # Restrict Amp management routes (/api/auth, /api/user, etc.) to localhost only (recommended)
|
||||||
# restrict-management-to-localhost: true
|
# restrict-management-to-localhost: true
|
||||||
|
# # Force model mappings to run before checking local API keys (default: false)
|
||||||
|
# force-model-mappings: false
|
||||||
# # Amp Model Mappings
|
# # Amp Model Mappings
|
||||||
# # Route unavailable Amp models to alternative models available in your local proxy.
|
# # Route unavailable Amp models to alternative models available in your local proxy.
|
||||||
# # Useful when Amp CLI requests models you don't have access to (e.g., Claude Opus 4.5)
|
# # Useful when Amp CLI requests models you don't have access to (e.g., Claude Opus 4.5)
|
||||||
|
|||||||
@@ -241,3 +241,11 @@ func (h *Handler) DeleteProxyURL(c *gin.Context) {
|
|||||||
h.cfg.ProxyURL = ""
|
h.cfg.ProxyURL = ""
|
||||||
h.persist(c)
|
h.persist(c)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Force Model Mappings (for Amp CLI)
|
||||||
|
func (h *Handler) GetForceModelMappings(c *gin.Context) {
|
||||||
|
c.JSON(200, gin.H{"force-model-mappings": h.cfg.AmpCode.ForceModelMappings})
|
||||||
|
}
|
||||||
|
func (h *Handler) PutForceModelMappings(c *gin.Context) {
|
||||||
|
h.updateBoolField(c, func(v bool) { h.cfg.AmpCode.ForceModelMappings = v })
|
||||||
|
}
|
||||||
|
|||||||
@@ -100,6 +100,16 @@ func (m *AmpModule) Name() string {
|
|||||||
return "amp-routing"
|
return "amp-routing"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// forceModelMappings returns whether model mappings should take precedence over local API keys
|
||||||
|
func (m *AmpModule) forceModelMappings() bool {
|
||||||
|
m.configMu.RLock()
|
||||||
|
defer m.configMu.RUnlock()
|
||||||
|
if m.lastConfig == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return m.lastConfig.ForceModelMappings
|
||||||
|
}
|
||||||
|
|
||||||
// Register sets up Amp routes if configured.
|
// Register sets up Amp routes if configured.
|
||||||
// This implements the RouteModuleV2 interface with Context.
|
// This implements the RouteModuleV2 interface with Context.
|
||||||
// Routes are registered only once via sync.Once for idempotent behavior.
|
// Routes are registered only once via sync.Once for idempotent behavior.
|
||||||
|
|||||||
@@ -28,6 +28,9 @@ const (
|
|||||||
RouteTypeNoProvider AmpRouteType = "NO_PROVIDER"
|
RouteTypeNoProvider AmpRouteType = "NO_PROVIDER"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// MappedModelContextKey is the Gin context key for passing mapped model names.
|
||||||
|
const MappedModelContextKey = "mapped_model"
|
||||||
|
|
||||||
// logAmpRouting logs the routing decision for an Amp request with structured fields
|
// logAmpRouting logs the routing decision for an Amp request with structured fields
|
||||||
func logAmpRouting(routeType AmpRouteType, requestedModel, resolvedModel, provider, path string) {
|
func logAmpRouting(routeType AmpRouteType, requestedModel, resolvedModel, provider, path string) {
|
||||||
fields := log.Fields{
|
fields := log.Fields{
|
||||||
@@ -74,23 +77,29 @@ func logAmpRouting(routeType AmpRouteType, requestedModel, resolvedModel, provid
|
|||||||
// FallbackHandler wraps a standard handler with fallback logic to ampcode.com
|
// FallbackHandler wraps a standard handler with fallback logic to ampcode.com
|
||||||
// when the model's provider is not available in CLIProxyAPI
|
// when the model's provider is not available in CLIProxyAPI
|
||||||
type FallbackHandler struct {
|
type FallbackHandler struct {
|
||||||
getProxy func() *httputil.ReverseProxy
|
getProxy func() *httputil.ReverseProxy
|
||||||
modelMapper ModelMapper
|
modelMapper ModelMapper
|
||||||
|
forceModelMappings func() bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewFallbackHandler creates a new fallback handler wrapper
|
// NewFallbackHandler creates a new fallback handler wrapper
|
||||||
// The getProxy function allows lazy evaluation of the proxy (useful when proxy is created after routes)
|
// The getProxy function allows lazy evaluation of the proxy (useful when proxy is created after routes)
|
||||||
func NewFallbackHandler(getProxy func() *httputil.ReverseProxy) *FallbackHandler {
|
func NewFallbackHandler(getProxy func() *httputil.ReverseProxy) *FallbackHandler {
|
||||||
return &FallbackHandler{
|
return &FallbackHandler{
|
||||||
getProxy: getProxy,
|
getProxy: getProxy,
|
||||||
|
forceModelMappings: func() bool { return false },
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewFallbackHandlerWithMapper creates a new fallback handler with model mapping support
|
// NewFallbackHandlerWithMapper creates a new fallback handler with model mapping support
|
||||||
func NewFallbackHandlerWithMapper(getProxy func() *httputil.ReverseProxy, mapper ModelMapper) *FallbackHandler {
|
func NewFallbackHandlerWithMapper(getProxy func() *httputil.ReverseProxy, mapper ModelMapper, forceModelMappings func() bool) *FallbackHandler {
|
||||||
|
if forceModelMappings == nil {
|
||||||
|
forceModelMappings = func() bool { return false }
|
||||||
|
}
|
||||||
return &FallbackHandler{
|
return &FallbackHandler{
|
||||||
getProxy: getProxy,
|
getProxy: getProxy,
|
||||||
modelMapper: mapper,
|
modelMapper: mapper,
|
||||||
|
forceModelMappings: forceModelMappings,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,32 +136,65 @@ func (fh *FallbackHandler) WrapHandler(handler gin.HandlerFunc) gin.HandlerFunc
|
|||||||
// Normalize model (handles Gemini thinking suffixes)
|
// Normalize model (handles Gemini thinking suffixes)
|
||||||
normalizedModel, _ := util.NormalizeGeminiThinkingModel(modelName)
|
normalizedModel, _ := util.NormalizeGeminiThinkingModel(modelName)
|
||||||
|
|
||||||
// Check if we have providers for this model
|
|
||||||
providers := util.GetProviderName(normalizedModel)
|
|
||||||
|
|
||||||
// Track resolved model for logging (may change if mapping is applied)
|
// Track resolved model for logging (may change if mapping is applied)
|
||||||
resolvedModel := normalizedModel
|
resolvedModel := normalizedModel
|
||||||
usedMapping := false
|
usedMapping := false
|
||||||
|
var providers []string
|
||||||
|
|
||||||
if len(providers) == 0 {
|
// Check if model mappings should be forced ahead of local API keys
|
||||||
// No providers configured - check if we have a model mapping
|
forceMappings := fh.forceModelMappings != nil && fh.forceModelMappings()
|
||||||
|
|
||||||
|
if forceMappings {
|
||||||
|
// FORCE MODE: Check model mappings FIRST (takes precedence over local API keys)
|
||||||
|
// This allows users to route Amp requests to their preferred OAuth providers
|
||||||
if fh.modelMapper != nil {
|
if fh.modelMapper != nil {
|
||||||
if mappedModel := fh.modelMapper.MapModel(normalizedModel); mappedModel != "" {
|
if mappedModel := fh.modelMapper.MapModel(normalizedModel); mappedModel != "" {
|
||||||
// Mapping found - rewrite the model in request body
|
// Mapping found - check if we have a provider for the mapped model
|
||||||
bodyBytes = rewriteModelInRequest(bodyBytes, mappedModel)
|
mappedProviders := util.GetProviderName(mappedModel)
|
||||||
c.Request.Body = io.NopCloser(bytes.NewReader(bodyBytes))
|
if len(mappedProviders) > 0 {
|
||||||
resolvedModel = mappedModel
|
// Mapping found and provider available - rewrite the model in request body
|
||||||
usedMapping = true
|
bodyBytes = rewriteModelInRequest(bodyBytes, mappedModel)
|
||||||
|
c.Request.Body = io.NopCloser(bytes.NewReader(bodyBytes))
|
||||||
// Get providers for the mapped model
|
// Store mapped model in context for handlers that check it (like gemini bridge)
|
||||||
providers = util.GetProviderName(mappedModel)
|
c.Set(MappedModelContextKey, mappedModel)
|
||||||
|
resolvedModel = mappedModel
|
||||||
// Continue to handler with remapped model
|
usedMapping = true
|
||||||
goto handleRequest
|
providers = mappedProviders
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// No mapping found - check if we have a proxy for fallback
|
// If no mapping applied, check for local providers
|
||||||
|
if !usedMapping {
|
||||||
|
providers = util.GetProviderName(normalizedModel)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// DEFAULT MODE: Check local providers first, then mappings as fallback
|
||||||
|
providers = util.GetProviderName(normalizedModel)
|
||||||
|
|
||||||
|
if len(providers) == 0 {
|
||||||
|
// No providers configured - check if we have a model mapping
|
||||||
|
if fh.modelMapper != nil {
|
||||||
|
if mappedModel := fh.modelMapper.MapModel(normalizedModel); mappedModel != "" {
|
||||||
|
// Mapping found - check if we have a provider for the mapped model
|
||||||
|
mappedProviders := util.GetProviderName(mappedModel)
|
||||||
|
if len(mappedProviders) > 0 {
|
||||||
|
// Mapping found and provider available - rewrite the model in request body
|
||||||
|
bodyBytes = rewriteModelInRequest(bodyBytes, mappedModel)
|
||||||
|
c.Request.Body = io.NopCloser(bytes.NewReader(bodyBytes))
|
||||||
|
// Store mapped model in context for handlers that check it (like gemini bridge)
|
||||||
|
c.Set(MappedModelContextKey, mappedModel)
|
||||||
|
resolvedModel = mappedModel
|
||||||
|
usedMapping = true
|
||||||
|
providers = mappedProviders
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no providers available, fallback to ampcode.com
|
||||||
|
if len(providers) == 0 {
|
||||||
proxy := fh.getProxy()
|
proxy := fh.getProxy()
|
||||||
if proxy != nil {
|
if proxy != nil {
|
||||||
// Log: Forwarding to ampcode.com (uses Amp credits)
|
// Log: Forwarding to ampcode.com (uses Amp credits)
|
||||||
@@ -170,8 +212,6 @@ func (fh *FallbackHandler) WrapHandler(handler gin.HandlerFunc) gin.HandlerFunc
|
|||||||
logAmpRouting(RouteTypeNoProvider, modelName, "", "", requestPath)
|
logAmpRouting(RouteTypeNoProvider, modelName, "", "", requestPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
handleRequest:
|
|
||||||
|
|
||||||
// Log the routing decision
|
// Log the routing decision
|
||||||
providerName := ""
|
providerName := ""
|
||||||
if len(providers) > 0 {
|
if len(providers) > 0 {
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/router-for-me/CLIProxyAPI/v6/sdk/api/handlers/gemini"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// createGeminiBridgeHandler creates a handler that bridges AMP CLI's non-standard Gemini paths
|
// createGeminiBridgeHandler creates a handler that bridges AMP CLI's non-standard Gemini paths
|
||||||
@@ -15,16 +14,31 @@ import (
|
|||||||
//
|
//
|
||||||
// This extracts the model+method from the AMP path and sets it as the :action parameter
|
// This extracts the model+method from the AMP path and sets it as the :action parameter
|
||||||
// so the standard Gemini handler can process it.
|
// so the standard Gemini handler can process it.
|
||||||
func createGeminiBridgeHandler(geminiHandler *gemini.GeminiAPIHandler) gin.HandlerFunc {
|
//
|
||||||
|
// The handler parameter should be a Gemini-compatible handler that expects the :action param.
|
||||||
|
func createGeminiBridgeHandler(handler gin.HandlerFunc) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
// Get the full path from the catch-all parameter
|
// Get the full path from the catch-all parameter
|
||||||
path := c.Param("path")
|
path := c.Param("path")
|
||||||
|
|
||||||
// Extract model:method from AMP CLI path format
|
// Extract model:method from AMP CLI path format
|
||||||
// Example: /publishers/google/models/gemini-3-pro-preview:streamGenerateContent
|
// Example: /publishers/google/models/gemini-3-pro-preview:streamGenerateContent
|
||||||
if idx := strings.Index(path, "/models/"); idx >= 0 {
|
const modelsPrefix = "/models/"
|
||||||
// Extract everything after "/models/"
|
if idx := strings.Index(path, modelsPrefix); idx >= 0 {
|
||||||
actionPart := path[idx+8:] // Skip "/models/"
|
// Extract everything after modelsPrefix
|
||||||
|
actionPart := path[idx+len(modelsPrefix):]
|
||||||
|
|
||||||
|
// Check if model was mapped by FallbackHandler
|
||||||
|
if mappedModel, exists := c.Get(MappedModelContextKey); exists {
|
||||||
|
if strModel, ok := mappedModel.(string); ok && strModel != "" {
|
||||||
|
// Replace the model part in the action
|
||||||
|
// actionPart is like "model-name:method"
|
||||||
|
if colonIdx := strings.Index(actionPart, ":"); colonIdx > 0 {
|
||||||
|
method := actionPart[colonIdx:] // ":method"
|
||||||
|
actionPart = strModel + method
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Set this as the :action parameter that the Gemini handler expects
|
// Set this as the :action parameter that the Gemini handler expects
|
||||||
c.Params = append(c.Params, gin.Param{
|
c.Params = append(c.Params, gin.Param{
|
||||||
@@ -32,8 +46,8 @@ func createGeminiBridgeHandler(geminiHandler *gemini.GeminiAPIHandler) gin.Handl
|
|||||||
Value: actionPart,
|
Value: actionPart,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Call the standard Gemini handler
|
// Call the handler
|
||||||
geminiHandler.GeminiHandler(c)
|
handler(c)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
93
internal/api/modules/amp/gemini_bridge_test.go
Normal file
93
internal/api/modules/amp/gemini_bridge_test.go
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
package amp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCreateGeminiBridgeHandler_ActionParameterExtraction(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
path string
|
||||||
|
mappedModel string // empty string means no mapping
|
||||||
|
expectedAction string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "no_mapping_uses_url_model",
|
||||||
|
path: "/publishers/google/models/gemini-pro:generateContent",
|
||||||
|
mappedModel: "",
|
||||||
|
expectedAction: "gemini-pro:generateContent",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "mapped_model_replaces_url_model",
|
||||||
|
path: "/publishers/google/models/gemini-exp:generateContent",
|
||||||
|
mappedModel: "gemini-2.0-flash",
|
||||||
|
expectedAction: "gemini-2.0-flash:generateContent",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "mapping_preserves_method",
|
||||||
|
path: "/publishers/google/models/gemini-2.5-preview:streamGenerateContent",
|
||||||
|
mappedModel: "gemini-flash",
|
||||||
|
expectedAction: "gemini-flash:streamGenerateContent",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
var capturedAction string
|
||||||
|
|
||||||
|
mockGeminiHandler := func(c *gin.Context) {
|
||||||
|
capturedAction = c.Param("action")
|
||||||
|
c.JSON(http.StatusOK, gin.H{"captured": capturedAction})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use the actual createGeminiBridgeHandler function
|
||||||
|
bridgeHandler := createGeminiBridgeHandler(mockGeminiHandler)
|
||||||
|
|
||||||
|
r := gin.New()
|
||||||
|
if tt.mappedModel != "" {
|
||||||
|
r.Use(func(c *gin.Context) {
|
||||||
|
c.Set(MappedModelContextKey, tt.mappedModel)
|
||||||
|
c.Next()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
r.POST("/api/provider/google/v1beta1/*path", bridgeHandler)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/provider/google/v1beta1"+tt.path, nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("Expected status 200, got %d", w.Code)
|
||||||
|
}
|
||||||
|
if capturedAction != tt.expectedAction {
|
||||||
|
t.Errorf("Expected action '%s', got '%s'", tt.expectedAction, capturedAction)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateGeminiBridgeHandler_InvalidPath(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
|
||||||
|
mockHandler := func(c *gin.Context) {
|
||||||
|
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||||
|
}
|
||||||
|
bridgeHandler := createGeminiBridgeHandler(mockHandler)
|
||||||
|
|
||||||
|
r := gin.New()
|
||||||
|
r.POST("/api/provider/google/v1beta1/*path", bridgeHandler)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/provider/google/v1beta1/invalid/path", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusBadRequest {
|
||||||
|
t.Errorf("Expected status 400 for invalid path, got %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,7 +9,6 @@ import (
|
|||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/logging"
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/logging"
|
||||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/util"
|
|
||||||
"github.com/router-for-me/CLIProxyAPI/v6/sdk/api/handlers"
|
"github.com/router-for-me/CLIProxyAPI/v6/sdk/api/handlers"
|
||||||
"github.com/router-for-me/CLIProxyAPI/v6/sdk/api/handlers/claude"
|
"github.com/router-for-me/CLIProxyAPI/v6/sdk/api/handlers/claude"
|
||||||
"github.com/router-for-me/CLIProxyAPI/v6/sdk/api/handlers/gemini"
|
"github.com/router-for-me/CLIProxyAPI/v6/sdk/api/handlers/gemini"
|
||||||
@@ -169,30 +168,22 @@ func (m *AmpModule) registerManagementRoutes(engine *gin.Engine, baseHandler *ha
|
|||||||
// We bridge these to our standard Gemini handler to enable local OAuth.
|
// We bridge these to our standard Gemini handler to enable local OAuth.
|
||||||
// If no local OAuth is available, falls back to ampcode.com proxy.
|
// If no local OAuth is available, falls back to ampcode.com proxy.
|
||||||
geminiHandlers := gemini.NewGeminiAPIHandler(baseHandler)
|
geminiHandlers := gemini.NewGeminiAPIHandler(baseHandler)
|
||||||
geminiBridge := createGeminiBridgeHandler(geminiHandlers)
|
geminiBridge := createGeminiBridgeHandler(geminiHandlers.GeminiHandler)
|
||||||
geminiV1Beta1Fallback := NewFallbackHandler(func() *httputil.ReverseProxy {
|
geminiV1Beta1Fallback := NewFallbackHandlerWithMapper(func() *httputil.ReverseProxy {
|
||||||
return m.getProxy()
|
return m.getProxy()
|
||||||
})
|
}, m.modelMapper, m.forceModelMappings)
|
||||||
geminiV1Beta1Handler := geminiV1Beta1Fallback.WrapHandler(geminiBridge)
|
geminiV1Beta1Handler := geminiV1Beta1Fallback.WrapHandler(geminiBridge)
|
||||||
|
|
||||||
// Route POST model calls through Gemini bridge when a local provider exists, otherwise proxy.
|
// Route POST model calls through Gemini bridge with FallbackHandler.
|
||||||
|
// FallbackHandler checks provider -> mapping -> proxy fallback automatically.
|
||||||
// All other methods (e.g., GET model listing) always proxy to upstream to preserve Amp CLI behavior.
|
// All other methods (e.g., GET model listing) always proxy to upstream to preserve Amp CLI behavior.
|
||||||
ampAPI.Any("/provider/google/v1beta1/*path", func(c *gin.Context) {
|
ampAPI.Any("/provider/google/v1beta1/*path", func(c *gin.Context) {
|
||||||
if c.Request.Method == "POST" {
|
if c.Request.Method == "POST" {
|
||||||
// Attempt to extract the model name from the AMP-style path
|
|
||||||
if path := c.Param("path"); strings.Contains(path, "/models/") {
|
if path := c.Param("path"); strings.Contains(path, "/models/") {
|
||||||
modelPart := path[strings.Index(path, "/models/")+len("/models/"):]
|
// POST with /models/ path -> use Gemini bridge with fallback handler
|
||||||
if colonIdx := strings.Index(modelPart, ":"); colonIdx > 0 {
|
// FallbackHandler will check provider/mapping and proxy if needed
|
||||||
modelPart = modelPart[:colonIdx]
|
geminiV1Beta1Handler(c)
|
||||||
}
|
return
|
||||||
if modelPart != "" {
|
|
||||||
normalized, _ := util.NormalizeGeminiThinkingModel(modelPart)
|
|
||||||
// Only handle locally when we have a provider; otherwise fall back to proxy
|
|
||||||
if providers := util.GetProviderName(normalized); len(providers) > 0 {
|
|
||||||
geminiV1Beta1Handler(c)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Non-POST or no local provider available -> proxy upstream
|
// Non-POST or no local provider available -> proxy upstream
|
||||||
@@ -218,7 +209,7 @@ func (m *AmpModule) registerProviderAliases(engine *gin.Engine, baseHandler *han
|
|||||||
// Also includes model mapping support for routing unavailable models to alternatives
|
// Also includes model mapping support for routing unavailable models to alternatives
|
||||||
fallbackHandler := NewFallbackHandlerWithMapper(func() *httputil.ReverseProxy {
|
fallbackHandler := NewFallbackHandlerWithMapper(func() *httputil.ReverseProxy {
|
||||||
return m.getProxy()
|
return m.getProxy()
|
||||||
}, m.modelMapper)
|
}, m.modelMapper, m.forceModelMappings)
|
||||||
|
|
||||||
// Provider-specific routes under /api/provider/:provider
|
// Provider-specific routes under /api/provider/:provider
|
||||||
ampProviders := engine.Group("/api/provider")
|
ampProviders := engine.Group("/api/provider")
|
||||||
|
|||||||
@@ -520,6 +520,10 @@ func (s *Server) registerManagementRoutes() {
|
|||||||
mgmt.PUT("/ws-auth", s.mgmt.PutWebsocketAuth)
|
mgmt.PUT("/ws-auth", s.mgmt.PutWebsocketAuth)
|
||||||
mgmt.PATCH("/ws-auth", s.mgmt.PutWebsocketAuth)
|
mgmt.PATCH("/ws-auth", s.mgmt.PutWebsocketAuth)
|
||||||
|
|
||||||
|
mgmt.GET("/force-model-mappings", s.mgmt.GetForceModelMappings)
|
||||||
|
mgmt.PUT("/force-model-mappings", s.mgmt.PutForceModelMappings)
|
||||||
|
mgmt.PATCH("/force-model-mappings", s.mgmt.PutForceModelMappings)
|
||||||
|
|
||||||
mgmt.GET("/request-retry", s.mgmt.GetRequestRetry)
|
mgmt.GET("/request-retry", s.mgmt.GetRequestRetry)
|
||||||
mgmt.PUT("/request-retry", s.mgmt.PutRequestRetry)
|
mgmt.PUT("/request-retry", s.mgmt.PutRequestRetry)
|
||||||
mgmt.PATCH("/request-retry", s.mgmt.PutRequestRetry)
|
mgmt.PATCH("/request-retry", s.mgmt.PutRequestRetry)
|
||||||
|
|||||||
@@ -309,17 +309,23 @@ func (ia *IFlowAuth) AuthenticateWithCookie(ctx context.Context, cookie string)
|
|||||||
return nil, fmt.Errorf("iflow cookie authentication: cookie is empty")
|
return nil, fmt.Errorf("iflow cookie authentication: cookie is empty")
|
||||||
}
|
}
|
||||||
|
|
||||||
// First, get initial API key information using GET request
|
// First, get initial API key information using GET request to obtain the name
|
||||||
keyInfo, err := ia.fetchAPIKeyInfo(ctx, cookie)
|
keyInfo, err := ia.fetchAPIKeyInfo(ctx, cookie)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("iflow cookie authentication: fetch initial API key info failed: %w", err)
|
return nil, fmt.Errorf("iflow cookie authentication: fetch initial API key info failed: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert to token data format
|
// Refresh the API key using POST request
|
||||||
|
refreshedKeyInfo, err := ia.RefreshAPIKey(ctx, cookie, keyInfo.Name)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("iflow cookie authentication: refresh API key failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert to token data format using refreshed key
|
||||||
data := &IFlowTokenData{
|
data := &IFlowTokenData{
|
||||||
APIKey: keyInfo.APIKey,
|
APIKey: refreshedKeyInfo.APIKey,
|
||||||
Expire: keyInfo.ExpireTime,
|
Expire: refreshedKeyInfo.ExpireTime,
|
||||||
Email: keyInfo.Name,
|
Email: refreshedKeyInfo.Name,
|
||||||
Cookie: cookie,
|
Cookie: cookie,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -143,6 +143,10 @@ type AmpCode struct {
|
|||||||
// When Amp requests a model that isn't available locally, these mappings
|
// When Amp requests a model that isn't available locally, these mappings
|
||||||
// allow routing to an alternative model that IS available.
|
// allow routing to an alternative model that IS available.
|
||||||
ModelMappings []AmpModelMapping `yaml:"model-mappings" json:"model-mappings"`
|
ModelMappings []AmpModelMapping `yaml:"model-mappings" json:"model-mappings"`
|
||||||
|
|
||||||
|
// ForceModelMappings when true, model mappings take precedence over local API keys.
|
||||||
|
// When false (default), local API keys are used first if available.
|
||||||
|
ForceModelMappings bool `yaml:"force-model-mappings" json:"force-model-mappings"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// PayloadConfig defines default and override parameter rules applied to provider payloads.
|
// PayloadConfig defines default and override parameter rules applied to provider payloads.
|
||||||
|
|||||||
@@ -56,6 +56,8 @@ type Content struct {
|
|||||||
// Part represents a distinct piece of content within a message.
|
// Part represents a distinct piece of content within a message.
|
||||||
// A part can be text, inline data (like an image), a function call, or a function response.
|
// A part can be text, inline data (like an image), a function call, or a function response.
|
||||||
type Part struct {
|
type Part struct {
|
||||||
|
Thought bool `json:"thought,omitempty"`
|
||||||
|
|
||||||
// Text contains plain text content.
|
// Text contains plain text content.
|
||||||
Text string `json:"text,omitempty"`
|
Text string `json:"text,omitempty"`
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import (
|
|||||||
"github.com/klauspost/compress/zstd"
|
"github.com/klauspost/compress/zstd"
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
|
|
||||||
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/buildinfo"
|
||||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces"
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces"
|
||||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/util"
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/util"
|
||||||
)
|
)
|
||||||
@@ -603,6 +604,7 @@ func (l *FileRequestLogger) formatRequestInfo(url, method string, headers map[st
|
|||||||
var content strings.Builder
|
var content strings.Builder
|
||||||
|
|
||||||
content.WriteString("=== REQUEST INFO ===\n")
|
content.WriteString("=== REQUEST INFO ===\n")
|
||||||
|
content.WriteString(fmt.Sprintf("Version: %s\n", buildinfo.Version))
|
||||||
content.WriteString(fmt.Sprintf("URL: %s\n", url))
|
content.WriteString(fmt.Sprintf("URL: %s\n", url))
|
||||||
content.WriteString(fmt.Sprintf("Method: %s\n", method))
|
content.WriteString(fmt.Sprintf("Method: %s\n", method))
|
||||||
content.WriteString(fmt.Sprintf("Timestamp: %s\n", time.Now().Format(time.RFC3339Nano)))
|
content.WriteString(fmt.Sprintf("Timestamp: %s\n", time.Now().Format(time.RFC3339Nano)))
|
||||||
|
|||||||
@@ -943,8 +943,19 @@ func GetQwenModels() []*ModelInfo {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetIFlowModels returns supported models for iFlow OAuth accounts.
|
// GetAntigravityThinkingConfig returns the Thinking configuration for antigravity models.
|
||||||
|
// Keys use the ALIASED model names (after modelName2Alias conversion) for direct lookup.
|
||||||
|
func GetAntigravityThinkingConfig() map[string]*ThinkingSupport {
|
||||||
|
return map[string]*ThinkingSupport{
|
||||||
|
"gemini-2.5-flash": {Min: 0, Max: 24576, ZeroAllowed: true, DynamicAllowed: true},
|
||||||
|
"gemini-2.5-flash-lite": {Min: 0, Max: 24576, ZeroAllowed: true, DynamicAllowed: true},
|
||||||
|
"gemini-3-pro-preview": {Min: 128, Max: 32768, ZeroAllowed: false, DynamicAllowed: true},
|
||||||
|
"gemini-claude-sonnet-4-5-thinking": {Min: 1024, Max: 200000, ZeroAllowed: false, DynamicAllowed: true},
|
||||||
|
"gemini-claude-opus-4-5-thinking": {Min: 1024, Max: 200000, ZeroAllowed: false, DynamicAllowed: true},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetIFlowModels returns supported models for iFlow OAuth accounts.
|
||||||
func GetIFlowModels() []*ModelInfo {
|
func GetIFlowModels() []*ModelInfo {
|
||||||
entries := []struct {
|
entries := []struct {
|
||||||
ID string
|
ID string
|
||||||
|
|||||||
@@ -366,29 +366,25 @@ func FetchAntigravityModels(ctx context.Context, auth *cliproxyauth.Auth, cfg *c
|
|||||||
}
|
}
|
||||||
|
|
||||||
now := time.Now().Unix()
|
now := time.Now().Unix()
|
||||||
|
thinkingConfig := registry.GetAntigravityThinkingConfig()
|
||||||
models := make([]*registry.ModelInfo, 0, len(result.Map()))
|
models := make([]*registry.ModelInfo, 0, len(result.Map()))
|
||||||
for id := range result.Map() {
|
for originalName := range result.Map() {
|
||||||
id = modelName2Alias(id)
|
aliasName := modelName2Alias(originalName)
|
||||||
if id != "" {
|
if aliasName != "" {
|
||||||
modelInfo := ®istry.ModelInfo{
|
modelInfo := ®istry.ModelInfo{
|
||||||
ID: id,
|
ID: aliasName,
|
||||||
Name: id,
|
Name: aliasName,
|
||||||
Description: id,
|
Description: aliasName,
|
||||||
DisplayName: id,
|
DisplayName: aliasName,
|
||||||
Version: id,
|
Version: aliasName,
|
||||||
Object: "model",
|
Object: "model",
|
||||||
Created: now,
|
Created: now,
|
||||||
OwnedBy: antigravityAuthType,
|
OwnedBy: antigravityAuthType,
|
||||||
Type: antigravityAuthType,
|
Type: antigravityAuthType,
|
||||||
}
|
}
|
||||||
// Add Thinking support for thinking models
|
// Look up Thinking support from static config using alias name
|
||||||
if strings.HasSuffix(id, "-thinking") || strings.Contains(id, "-thinking-") {
|
if thinking, ok := thinkingConfig[aliasName]; ok {
|
||||||
modelInfo.Thinking = ®istry.ThinkingSupport{
|
modelInfo.Thinking = thinking
|
||||||
Min: 1024,
|
|
||||||
Max: 100000,
|
|
||||||
ZeroAllowed: false,
|
|
||||||
DynamicAllowed: true,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
models = append(models, modelInfo)
|
models = append(models, modelInfo)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -83,7 +83,15 @@ func ConvertClaudeRequestToAntigravity(modelName string, inputRawJSON []byte, _
|
|||||||
for j := 0; j < len(contentResults); j++ {
|
for j := 0; j < len(contentResults); j++ {
|
||||||
contentResult := contentResults[j]
|
contentResult := contentResults[j]
|
||||||
contentTypeResult := contentResult.Get("type")
|
contentTypeResult := contentResult.Get("type")
|
||||||
if contentTypeResult.Type == gjson.String && contentTypeResult.String() == "text" {
|
if contentTypeResult.Type == gjson.String && contentTypeResult.String() == "thinking" {
|
||||||
|
prompt := contentResult.Get("thinking").String()
|
||||||
|
signatureResult := contentResult.Get("signature")
|
||||||
|
signature := geminiCLIClaudeThoughtSignature
|
||||||
|
if signatureResult.Exists() {
|
||||||
|
signature = signatureResult.String()
|
||||||
|
}
|
||||||
|
clientContent.Parts = append(clientContent.Parts, client.Part{Text: prompt, Thought: true, ThoughtSignature: signature})
|
||||||
|
} else if contentTypeResult.Type == gjson.String && contentTypeResult.String() == "text" {
|
||||||
prompt := contentResult.Get("text").String()
|
prompt := contentResult.Get("text").String()
|
||||||
clientContent.Parts = append(clientContent.Parts, client.Part{Text: prompt})
|
clientContent.Parts = append(clientContent.Parts, client.Part{Text: prompt})
|
||||||
} else if contentTypeResult.Type == gjson.String && contentTypeResult.String() == "tool_use" {
|
} else if contentTypeResult.Type == gjson.String && contentTypeResult.String() == "tool_use" {
|
||||||
@@ -92,10 +100,16 @@ func ConvertClaudeRequestToAntigravity(modelName string, inputRawJSON []byte, _
|
|||||||
functionID := contentResult.Get("id").String()
|
functionID := contentResult.Get("id").String()
|
||||||
var args map[string]any
|
var args map[string]any
|
||||||
if err := json.Unmarshal([]byte(functionArgs), &args); err == nil {
|
if err := json.Unmarshal([]byte(functionArgs), &args); err == nil {
|
||||||
clientContent.Parts = append(clientContent.Parts, client.Part{
|
if strings.Contains(modelName, "claude") {
|
||||||
FunctionCall: &client.FunctionCall{ID: functionID, Name: functionName, Args: args},
|
clientContent.Parts = append(clientContent.Parts, client.Part{
|
||||||
ThoughtSignature: geminiCLIClaudeThoughtSignature,
|
FunctionCall: &client.FunctionCall{ID: functionID, Name: functionName, Args: args},
|
||||||
})
|
})
|
||||||
|
} else {
|
||||||
|
clientContent.Parts = append(clientContent.Parts, client.Part{
|
||||||
|
FunctionCall: &client.FunctionCall{ID: functionID, Name: functionName, Args: args},
|
||||||
|
ThoughtSignature: geminiCLIClaudeThoughtSignature,
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else if contentTypeResult.Type == gjson.String && contentTypeResult.String() == "tool_result" {
|
} else if contentTypeResult.Type == gjson.String && contentTypeResult.String() == "tool_result" {
|
||||||
toolCallID := contentResult.Get("tool_use_id").String()
|
toolCallID := contentResult.Get("tool_use_id").String()
|
||||||
|
|||||||
@@ -111,8 +111,11 @@ func ConvertAntigravityResponseToClaude(_ context.Context, _ string, originalReq
|
|||||||
if partTextResult.Exists() {
|
if partTextResult.Exists() {
|
||||||
// Process thinking content (internal reasoning)
|
// Process thinking content (internal reasoning)
|
||||||
if partResult.Get("thought").Bool() {
|
if partResult.Get("thought").Bool() {
|
||||||
// Continue existing thinking block if already in thinking state
|
if thoughtSignature := partResult.Get("thoughtSignature"); thoughtSignature.Exists() && thoughtSignature.String() != "" {
|
||||||
if params.ResponseType == 2 {
|
output = output + "event: content_block_delta\n"
|
||||||
|
data, _ := sjson.Set(fmt.Sprintf(`{"type":"content_block_delta","index":%d,"delta":{"type":"signature_delta","signature":""}}`, params.ResponseIndex), "delta.signature", thoughtSignature.String())
|
||||||
|
output = output + fmt.Sprintf("data: %s\n\n\n", data)
|
||||||
|
} else if params.ResponseType == 2 { // Continue existing thinking block if already in thinking state
|
||||||
output = output + "event: content_block_delta\n"
|
output = output + "event: content_block_delta\n"
|
||||||
data, _ := sjson.Set(fmt.Sprintf(`{"type":"content_block_delta","index":%d,"delta":{"type":"thinking_delta","thinking":""}}`, params.ResponseIndex), "delta.thinking", partTextResult.String())
|
data, _ := sjson.Set(fmt.Sprintf(`{"type":"content_block_delta","index":%d,"delta":{"type":"thinking_delta","thinking":""}}`, params.ResponseIndex), "delta.thinking", partTextResult.String())
|
||||||
output = output + fmt.Sprintf("data: %s\n\n\n", data)
|
output = output + fmt.Sprintf("data: %s\n\n\n", data)
|
||||||
@@ -163,15 +166,16 @@ func ConvertAntigravityResponseToClaude(_ context.Context, _ string, originalReq
|
|||||||
output = output + "\n\n\n"
|
output = output + "\n\n\n"
|
||||||
params.ResponseIndex++
|
params.ResponseIndex++
|
||||||
}
|
}
|
||||||
|
if partTextResult.String() != "" {
|
||||||
// Start a new text content block
|
// Start a new text content block
|
||||||
output = output + "event: content_block_start\n"
|
output = output + "event: content_block_start\n"
|
||||||
output = output + fmt.Sprintf(`data: {"type":"content_block_start","index":%d,"content_block":{"type":"text","text":""}}`, params.ResponseIndex)
|
output = output + fmt.Sprintf(`data: {"type":"content_block_start","index":%d,"content_block":{"type":"text","text":""}}`, params.ResponseIndex)
|
||||||
output = output + "\n\n\n"
|
output = output + "\n\n\n"
|
||||||
output = output + "event: content_block_delta\n"
|
output = output + "event: content_block_delta\n"
|
||||||
data, _ := sjson.Set(fmt.Sprintf(`{"type":"content_block_delta","index":%d,"delta":{"type":"text_delta","text":""}}`, params.ResponseIndex), "delta.text", partTextResult.String())
|
data, _ := sjson.Set(fmt.Sprintf(`{"type":"content_block_delta","index":%d,"delta":{"type":"text_delta","text":""}}`, params.ResponseIndex), "delta.text", partTextResult.String())
|
||||||
output = output + fmt.Sprintf("data: %s\n\n\n", data)
|
output = output + fmt.Sprintf("data: %s\n\n\n", data)
|
||||||
params.ResponseType = 1 // Set state to content
|
params.ResponseType = 1 // Set state to content
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -88,6 +88,20 @@ func ConvertOpenAIRequestToAntigravity(modelName string, inputRawJSON []byte, _
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Claude/Anthropic API format: thinking.type == "enabled" with budget_tokens
|
||||||
|
// This allows Claude Code and other Claude API clients to pass thinking configuration
|
||||||
|
if !gjson.GetBytes(out, "request.generationConfig.thinkingConfig").Exists() && util.ModelSupportsThinking(modelName) {
|
||||||
|
if t := gjson.GetBytes(rawJSON, "thinking"); t.Exists() && t.IsObject() {
|
||||||
|
if t.Get("type").String() == "enabled" {
|
||||||
|
if b := t.Get("budget_tokens"); b.Exists() && b.Type == gjson.Number {
|
||||||
|
budget := util.NormalizeThinkingBudget(modelName, int(b.Int()))
|
||||||
|
out, _ = sjson.SetBytes(out, "request.generationConfig.thinkingConfig.thinkingBudget", budget)
|
||||||
|
out, _ = sjson.SetBytes(out, "request.generationConfig.thinkingConfig.include_thoughts", true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// For gemini-3-pro-preview, always send default thinkingConfig when none specified.
|
// For gemini-3-pro-preview, always send default thinkingConfig when none specified.
|
||||||
// This matches the official Gemini CLI behavior which always sends:
|
// This matches the official Gemini CLI behavior which always sends:
|
||||||
// { thinkingBudget: -1, includeThoughts: true }
|
// { thinkingBudget: -1, includeThoughts: true }
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ package claude
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/tidwall/gjson"
|
"github.com/tidwall/gjson"
|
||||||
"github.com/tidwall/sjson"
|
"github.com/tidwall/sjson"
|
||||||
@@ -242,11 +243,12 @@ func convertClaudeContentPart(part gjson.Result) (string, bool) {
|
|||||||
|
|
||||||
switch partType {
|
switch partType {
|
||||||
case "text":
|
case "text":
|
||||||
if !part.Get("text").Exists() {
|
text := part.Get("text").String()
|
||||||
|
if strings.TrimSpace(text) == "" {
|
||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
textContent := `{"type":"text","text":""}`
|
textContent := `{"type":"text","text":""}`
|
||||||
textContent, _ = sjson.Set(textContent, "text", part.Get("text").String())
|
textContent, _ = sjson.Set(textContent, "text", text)
|
||||||
return textContent, true
|
return textContent, true
|
||||||
|
|
||||||
case "image":
|
case "image":
|
||||||
|
|||||||
Reference in New Issue
Block a user