mirror of
https://github.com/router-for-me/CLIProxyAPIPlus.git
synced 2026-03-29 16:54:41 +00:00
Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4b01ecba2e | ||
|
|
d7564173dd | ||
|
|
f241124599 | ||
|
|
c44c46dd80 | ||
|
|
aa810ee719 | ||
|
|
412148af0e | ||
|
|
5d2baf6058 | ||
|
|
d28258501a | ||
|
|
55cd31fb96 | ||
|
|
d138df07bf | ||
|
|
c5df8e7897 | ||
|
|
d4d529833d | ||
|
|
caa48e7c6f | ||
|
|
acdfb3bceb | ||
|
|
89d68962b1 | ||
|
|
691cdb6bdf | ||
|
|
8064cba288 | ||
|
|
361443db10 | ||
|
|
d6352dd4d4 |
@@ -1,16 +1,28 @@
|
|||||||
package management
|
package management
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
||||||
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/util"
|
||||||
|
sdkconfig "github.com/router-for-me/CLIProxyAPI/v6/sdk/config"
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
"gopkg.in/yaml.v3"
|
"gopkg.in/yaml.v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
latestReleaseURL = "https://api.github.com/repos/router-for-me/CLIProxyAPIPlus/releases/latest"
|
||||||
|
latestReleaseUserAgent = "CLIProxyAPIPlus"
|
||||||
|
)
|
||||||
|
|
||||||
func (h *Handler) GetConfig(c *gin.Context) {
|
func (h *Handler) GetConfig(c *gin.Context) {
|
||||||
if h == nil || h.cfg == nil {
|
if h == nil || h.cfg == nil {
|
||||||
c.JSON(200, gin.H{})
|
c.JSON(200, gin.H{})
|
||||||
@@ -20,6 +32,66 @@ func (h *Handler) GetConfig(c *gin.Context) {
|
|||||||
c.JSON(200, &cfgCopy)
|
c.JSON(200, &cfgCopy)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type releaseInfo struct {
|
||||||
|
TagName string `json:"tag_name"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetLatestVersion returns the latest release version from GitHub without downloading assets.
|
||||||
|
func (h *Handler) GetLatestVersion(c *gin.Context) {
|
||||||
|
client := &http.Client{Timeout: 10 * time.Second}
|
||||||
|
proxyURL := ""
|
||||||
|
if h != nil && h.cfg != nil {
|
||||||
|
proxyURL = strings.TrimSpace(h.cfg.ProxyURL)
|
||||||
|
}
|
||||||
|
if proxyURL != "" {
|
||||||
|
sdkCfg := &sdkconfig.SDKConfig{ProxyURL: proxyURL}
|
||||||
|
util.SetProxy(sdkCfg, client)
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(c.Request.Context(), http.MethodGet, latestReleaseURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "request_create_failed", "message": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
req.Header.Set("Accept", "application/vnd.github+json")
|
||||||
|
req.Header.Set("User-Agent", latestReleaseUserAgent)
|
||||||
|
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadGateway, gin.H{"error": "request_failed", "message": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
if errClose := resp.Body.Close(); errClose != nil {
|
||||||
|
log.WithError(errClose).Debug("failed to close latest version response body")
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
|
||||||
|
c.JSON(http.StatusBadGateway, gin.H{"error": "unexpected_status", "message": fmt.Sprintf("status %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var info releaseInfo
|
||||||
|
if errDecode := json.NewDecoder(resp.Body).Decode(&info); errDecode != nil {
|
||||||
|
c.JSON(http.StatusBadGateway, gin.H{"error": "decode_failed", "message": errDecode.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
version := strings.TrimSpace(info.TagName)
|
||||||
|
if version == "" {
|
||||||
|
version = strings.TrimSpace(info.Name)
|
||||||
|
}
|
||||||
|
if version == "" {
|
||||||
|
c.JSON(http.StatusBadGateway, gin.H{"error": "invalid_response", "message": "missing release version"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"latest-version": version})
|
||||||
|
}
|
||||||
|
|
||||||
func WriteConfig(path string, data []byte) error {
|
func WriteConfig(path string, data []byte) error {
|
||||||
data = config.NormalizeCommentIndentation(data)
|
data = config.NormalizeCommentIndentation(data)
|
||||||
f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0644)
|
f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0644)
|
||||||
|
|||||||
@@ -112,5 +112,10 @@ func shouldLogRequest(path string) bool {
|
|||||||
if strings.HasPrefix(path, "/v0/management") || strings.HasPrefix(path, "/management") {
|
if strings.HasPrefix(path, "/v0/management") || strings.HasPrefix(path, "/management") {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if strings.HasPrefix(path, "/api") {
|
||||||
|
return strings.HasPrefix(path, "/api/provider")
|
||||||
|
}
|
||||||
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -126,6 +126,9 @@ func (m *AmpModule) Register(ctx modules.Context) error {
|
|||||||
// Always register provider aliases - these work without an upstream
|
// Always register provider aliases - these work without an upstream
|
||||||
m.registerProviderAliases(ctx.Engine, ctx.BaseHandler, auth)
|
m.registerProviderAliases(ctx.Engine, ctx.BaseHandler, auth)
|
||||||
|
|
||||||
|
// Register management proxy routes once; middleware will gate access when upstream is unavailable.
|
||||||
|
m.registerManagementRoutes(ctx.Engine, ctx.BaseHandler)
|
||||||
|
|
||||||
// If no upstream URL, skip proxy routes but provider aliases are still available
|
// If no upstream URL, skip proxy routes but provider aliases are still available
|
||||||
if upstreamURL == "" {
|
if upstreamURL == "" {
|
||||||
log.Debug("amp upstream proxy disabled (no upstream URL configured)")
|
log.Debug("amp upstream proxy disabled (no upstream URL configured)")
|
||||||
@@ -134,27 +137,11 @@ func (m *AmpModule) Register(ctx modules.Context) error {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create secret source with precedence: config > env > file
|
if err := m.enableUpstreamProxy(upstreamURL, &settings); err != nil {
|
||||||
// Cache secrets for 5 minutes to reduce file I/O
|
|
||||||
if m.secretSource == nil {
|
|
||||||
m.secretSource = NewMultiSourceSecret(settings.UpstreamAPIKey, 0 /* default 5min */)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create reverse proxy with gzip handling via ModifyResponse
|
|
||||||
proxy, err := createReverseProxy(upstreamURL, m.secretSource)
|
|
||||||
if err != nil {
|
|
||||||
regErr = fmt.Errorf("failed to create amp proxy: %w", err)
|
regErr = fmt.Errorf("failed to create amp proxy: %w", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
m.setProxy(proxy)
|
|
||||||
m.enabled = true
|
|
||||||
|
|
||||||
// Register management proxy routes (requires upstream)
|
|
||||||
// Uses dynamic middleware that checks m.IsRestrictedToLocalhost() for hot-reload support
|
|
||||||
m.registerManagementRoutes(ctx.Engine, ctx.BaseHandler)
|
|
||||||
|
|
||||||
log.Infof("amp upstream proxy enabled for: %s", upstreamURL)
|
|
||||||
log.Debug("amp provider alias routes registered")
|
log.Debug("amp provider alias routes registered")
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -188,18 +175,30 @@ func (m *AmpModule) OnConfigUpdated(cfg *config.Config) error {
|
|||||||
oldSettings := m.lastConfig
|
oldSettings := m.lastConfig
|
||||||
m.configMu.RUnlock()
|
m.configMu.RUnlock()
|
||||||
|
|
||||||
// Track what changed for logging
|
if oldSettings != nil && oldSettings.RestrictManagementToLocalhost != newSettings.RestrictManagementToLocalhost {
|
||||||
var changes []string
|
m.setRestrictToLocalhost(newSettings.RestrictManagementToLocalhost)
|
||||||
|
if !newSettings.RestrictManagementToLocalhost {
|
||||||
|
log.Warnf("amp management routes now accessible from any IP - this is insecure!")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
newUpstreamURL := strings.TrimSpace(newSettings.UpstreamURL)
|
||||||
|
oldUpstreamURL := ""
|
||||||
|
if oldSettings != nil {
|
||||||
|
oldUpstreamURL = strings.TrimSpace(oldSettings.UpstreamURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !m.enabled && newUpstreamURL != "" {
|
||||||
|
if err := m.enableUpstreamProxy(newUpstreamURL, &newSettings); err != nil {
|
||||||
|
log.Errorf("amp config: failed to enable upstream proxy for %s: %v", newUpstreamURL, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Check model mappings change
|
// Check model mappings change
|
||||||
modelMappingsChanged := m.hasModelMappingsChanged(oldSettings, &newSettings)
|
modelMappingsChanged := m.hasModelMappingsChanged(oldSettings, &newSettings)
|
||||||
if modelMappingsChanged {
|
if modelMappingsChanged {
|
||||||
if m.modelMapper != nil {
|
if m.modelMapper != nil {
|
||||||
m.modelMapper.UpdateMappings(newSettings.ModelMappings)
|
m.modelMapper.UpdateMappings(newSettings.ModelMappings)
|
||||||
changes = append(changes, "model-mappings")
|
|
||||||
if m.enabled {
|
|
||||||
log.Infof("amp config partial reload: model mappings updated (%d entries)", len(newSettings.ModelMappings))
|
|
||||||
}
|
|
||||||
} else if m.enabled {
|
} else if m.enabled {
|
||||||
log.Warnf("amp model mapper not initialized, skipping model mapping update")
|
log.Warnf("amp model mapper not initialized, skipping model mapping update")
|
||||||
}
|
}
|
||||||
@@ -207,25 +206,16 @@ func (m *AmpModule) OnConfigUpdated(cfg *config.Config) error {
|
|||||||
|
|
||||||
if m.enabled {
|
if m.enabled {
|
||||||
// Check upstream URL change - now supports hot-reload
|
// Check upstream URL change - now supports hot-reload
|
||||||
newUpstreamURL := strings.TrimSpace(newSettings.UpstreamURL)
|
|
||||||
oldUpstreamURL := ""
|
|
||||||
if oldSettings != nil {
|
|
||||||
oldUpstreamURL = strings.TrimSpace(oldSettings.UpstreamURL)
|
|
||||||
}
|
|
||||||
|
|
||||||
if newUpstreamURL == "" && oldUpstreamURL != "" {
|
if newUpstreamURL == "" && oldUpstreamURL != "" {
|
||||||
log.Warn("amp upstream URL removed from config, proxy has been disabled")
|
|
||||||
m.setProxy(nil)
|
m.setProxy(nil)
|
||||||
changes = append(changes, "upstream-url(disabled)")
|
m.enabled = false
|
||||||
} else if newUpstreamURL != oldUpstreamURL && newUpstreamURL != "" {
|
} else if oldUpstreamURL != "" && newUpstreamURL != oldUpstreamURL && newUpstreamURL != "" {
|
||||||
// Recreate proxy with new URL
|
// Recreate proxy with new URL
|
||||||
proxy, err := createReverseProxy(newUpstreamURL, m.secretSource)
|
proxy, err := createReverseProxy(newUpstreamURL, m.secretSource)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Errorf("amp config: failed to create proxy for new upstream URL %s: %v", newUpstreamURL, err)
|
log.Errorf("amp config: failed to create proxy for new upstream URL %s: %v", newUpstreamURL, err)
|
||||||
} else {
|
} else {
|
||||||
m.setProxy(proxy)
|
m.setProxy(proxy)
|
||||||
changes = append(changes, "upstream-url")
|
|
||||||
log.Infof("amp config partial reload: upstream URL updated (%s -> %s)", oldUpstreamURL, newUpstreamURL)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -236,22 +226,10 @@ func (m *AmpModule) OnConfigUpdated(cfg *config.Config) error {
|
|||||||
if ms, ok := m.secretSource.(*MultiSourceSecret); ok {
|
if ms, ok := m.secretSource.(*MultiSourceSecret); ok {
|
||||||
ms.UpdateExplicitKey(newSettings.UpstreamAPIKey)
|
ms.UpdateExplicitKey(newSettings.UpstreamAPIKey)
|
||||||
ms.InvalidateCache()
|
ms.InvalidateCache()
|
||||||
changes = append(changes, "upstream-api-key")
|
|
||||||
log.Debug("amp config partial reload: secret cache invalidated")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check restrict-management-to-localhost change - now supports hot-reload
|
|
||||||
if oldSettings != nil && oldSettings.RestrictManagementToLocalhost != newSettings.RestrictManagementToLocalhost {
|
|
||||||
m.setRestrictToLocalhost(newSettings.RestrictManagementToLocalhost)
|
|
||||||
changes = append(changes, "restrict-management-to-localhost")
|
|
||||||
if newSettings.RestrictManagementToLocalhost {
|
|
||||||
log.Infof("amp config partial reload: management routes now restricted to localhost")
|
|
||||||
} else {
|
|
||||||
log.Warnf("amp config partial reload: management routes now accessible from any IP - this is insecure!")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Store current config for next comparison
|
// Store current config for next comparison
|
||||||
@@ -260,13 +238,26 @@ func (m *AmpModule) OnConfigUpdated(cfg *config.Config) error {
|
|||||||
m.lastConfig = &settingsCopy
|
m.lastConfig = &settingsCopy
|
||||||
m.configMu.Unlock()
|
m.configMu.Unlock()
|
||||||
|
|
||||||
// Log summary if any changes detected
|
return nil
|
||||||
if len(changes) > 0 {
|
}
|
||||||
log.Debugf("amp config partial reload completed: %v", changes)
|
|
||||||
} else {
|
func (m *AmpModule) enableUpstreamProxy(upstreamURL string, settings *config.AmpCode) error {
|
||||||
log.Debug("amp config checked: no changes detected")
|
if m.secretSource == nil {
|
||||||
|
m.secretSource = NewMultiSourceSecret(settings.UpstreamAPIKey, 0 /* default 5min */)
|
||||||
|
} else if ms, ok := m.secretSource.(*MultiSourceSecret); ok {
|
||||||
|
ms.UpdateExplicitKey(settings.UpstreamAPIKey)
|
||||||
|
ms.InvalidateCache()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
proxy, err := createReverseProxy(upstreamURL, m.secretSource)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
m.setProxy(proxy)
|
||||||
|
m.enabled = true
|
||||||
|
|
||||||
|
log.Infof("amp upstream proxy enabled for: %s", upstreamURL)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -48,25 +48,25 @@ func logAmpRouting(routeType AmpRouteType, requestedModel, resolvedModel, provid
|
|||||||
case RouteTypeLocalProvider:
|
case RouteTypeLocalProvider:
|
||||||
fields["cost"] = "free"
|
fields["cost"] = "free"
|
||||||
fields["source"] = "local_oauth"
|
fields["source"] = "local_oauth"
|
||||||
log.WithFields(fields).Infof("[amp] using local provider for model: %s", requestedModel)
|
log.WithFields(fields).Debugf("amp using local provider for model: %s", requestedModel)
|
||||||
|
|
||||||
case RouteTypeModelMapping:
|
case RouteTypeModelMapping:
|
||||||
fields["cost"] = "free"
|
fields["cost"] = "free"
|
||||||
fields["source"] = "local_oauth"
|
fields["source"] = "local_oauth"
|
||||||
fields["mapping"] = requestedModel + " -> " + resolvedModel
|
fields["mapping"] = requestedModel + " -> " + resolvedModel
|
||||||
log.WithFields(fields).Infof("[amp] model mapped: %s -> %s", requestedModel, resolvedModel)
|
// model mapping already logged in mapper; avoid duplicate here
|
||||||
|
|
||||||
case RouteTypeAmpCredits:
|
case RouteTypeAmpCredits:
|
||||||
fields["cost"] = "amp_credits"
|
fields["cost"] = "amp_credits"
|
||||||
fields["source"] = "ampcode.com"
|
fields["source"] = "ampcode.com"
|
||||||
fields["model_id"] = requestedModel // Explicit model_id for easy config reference
|
fields["model_id"] = requestedModel // Explicit model_id for easy config reference
|
||||||
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)
|
log.WithFields(fields).Warnf("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)
|
||||||
|
|
||||||
case RouteTypeNoProvider:
|
case RouteTypeNoProvider:
|
||||||
fields["cost"] = "none"
|
fields["cost"] = "none"
|
||||||
fields["source"] = "error"
|
fields["source"] = "error"
|
||||||
fields["model_id"] = requestedModel // Explicit model_id for easy config reference
|
fields["model_id"] = requestedModel // Explicit model_id for easy config reference
|
||||||
log.WithFields(fields).Warnf("[amp] no provider available for model_id: %s", requestedModel)
|
log.WithFields(fields).Warnf("no provider available for model_id: %s", requestedModel)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
package amp
|
package amp
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"net"
|
"net"
|
||||||
|
"net/http"
|
||||||
"net/http/httputil"
|
"net/http/httputil"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"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/util"
|
"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"
|
||||||
@@ -78,6 +81,21 @@ func noCORSMiddleware() gin.HandlerFunc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// managementAvailabilityMiddleware short-circuits management routes when the upstream
|
||||||
|
// proxy is disabled, preventing noisy localhost warnings and accidental exposure.
|
||||||
|
func (m *AmpModule) managementAvailabilityMiddleware() gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
if m.getProxy() == nil {
|
||||||
|
logging.SkipGinRequestLogging(c)
|
||||||
|
c.AbortWithStatusJSON(http.StatusServiceUnavailable, gin.H{
|
||||||
|
"error": "amp upstream proxy not available",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// registerManagementRoutes registers Amp management proxy routes
|
// registerManagementRoutes registers Amp management proxy routes
|
||||||
// These routes proxy through to the Amp control plane for OAuth, user management, etc.
|
// These routes proxy through to the Amp control plane for OAuth, user management, etc.
|
||||||
// Uses dynamic middleware and proxy getter for hot-reload support.
|
// Uses dynamic middleware and proxy getter for hot-reload support.
|
||||||
@@ -85,19 +103,28 @@ func (m *AmpModule) registerManagementRoutes(engine *gin.Engine, baseHandler *ha
|
|||||||
ampAPI := engine.Group("/api")
|
ampAPI := engine.Group("/api")
|
||||||
|
|
||||||
// Always disable CORS for management routes to prevent browser-based attacks
|
// Always disable CORS for management routes to prevent browser-based attacks
|
||||||
ampAPI.Use(noCORSMiddleware())
|
ampAPI.Use(m.managementAvailabilityMiddleware(), noCORSMiddleware())
|
||||||
|
|
||||||
// Apply dynamic localhost-only restriction (hot-reloadable via m.IsRestrictedToLocalhost())
|
// Apply dynamic localhost-only restriction (hot-reloadable via m.IsRestrictedToLocalhost())
|
||||||
ampAPI.Use(m.localhostOnlyMiddleware())
|
ampAPI.Use(m.localhostOnlyMiddleware())
|
||||||
|
|
||||||
if m.IsRestrictedToLocalhost() {
|
if !m.IsRestrictedToLocalhost() {
|
||||||
log.Info("amp management routes restricted to localhost only (CORS disabled)")
|
|
||||||
} else {
|
|
||||||
log.Warn("amp management routes are NOT restricted to localhost - this is insecure!")
|
log.Warn("amp management routes are NOT restricted to localhost - this is insecure!")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Dynamic proxy handler that uses m.getProxy() for hot-reload support
|
// Dynamic proxy handler that uses m.getProxy() for hot-reload support
|
||||||
proxyHandler := func(c *gin.Context) {
|
proxyHandler := func(c *gin.Context) {
|
||||||
|
// Swallow ErrAbortHandler panics from ReverseProxy copyResponse to avoid noisy stack traces
|
||||||
|
defer func() {
|
||||||
|
if rec := recover(); rec != nil {
|
||||||
|
if err, ok := rec.(error); ok && errors.Is(err, http.ErrAbortHandler) {
|
||||||
|
// Upstream already wrote the status (often 404) before the client/stream ended.
|
||||||
|
return
|
||||||
|
}
|
||||||
|
panic(rec)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
proxy := m.getProxy()
|
proxy := m.getProxy()
|
||||||
if proxy == nil {
|
if proxy == nil {
|
||||||
c.JSON(503, gin.H{"error": "amp upstream proxy not available"})
|
c.JSON(503, gin.H{"error": "amp upstream proxy not available"})
|
||||||
@@ -127,7 +154,8 @@ func (m *AmpModule) registerManagementRoutes(engine *gin.Engine, baseHandler *ha
|
|||||||
|
|
||||||
// Root-level routes that AMP CLI expects without /api prefix
|
// Root-level routes that AMP CLI expects without /api prefix
|
||||||
// These need the same security middleware as the /api/* routes (dynamic for hot-reload)
|
// These need the same security middleware as the /api/* routes (dynamic for hot-reload)
|
||||||
rootMiddleware := []gin.HandlerFunc{noCORSMiddleware(), m.localhostOnlyMiddleware()}
|
rootMiddleware := []gin.HandlerFunc{m.managementAvailabilityMiddleware(), noCORSMiddleware(), m.localhostOnlyMiddleware()}
|
||||||
|
engine.GET("/threads/*path", append(rootMiddleware, proxyHandler)...)
|
||||||
engine.GET("/threads.rss", append(rootMiddleware, proxyHandler)...)
|
engine.GET("/threads.rss", append(rootMiddleware, proxyHandler)...)
|
||||||
|
|
||||||
// Root-level auth routes for CLI login flow
|
// Root-level auth routes for CLI login flow
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ func TestRegisterManagementRoutes(t *testing.T) {
|
|||||||
{"/api/meta", http.MethodGet},
|
{"/api/meta", http.MethodGet},
|
||||||
{"/api/telemetry", http.MethodGet},
|
{"/api/telemetry", http.MethodGet},
|
||||||
{"/api/threads", http.MethodGet},
|
{"/api/threads", http.MethodGet},
|
||||||
|
{"/threads/", http.MethodGet},
|
||||||
{"/threads.rss", http.MethodGet}, // Root-level route (no /api prefix)
|
{"/threads.rss", http.MethodGet}, // Root-level route (no /api prefix)
|
||||||
{"/api/otel", http.MethodGet},
|
{"/api/otel", http.MethodGet},
|
||||||
{"/api/tab", http.MethodGet},
|
{"/api/tab", http.MethodGet},
|
||||||
|
|||||||
@@ -472,6 +472,7 @@ func (s *Server) registerManagementRoutes() {
|
|||||||
mgmt.GET("/config", s.mgmt.GetConfig)
|
mgmt.GET("/config", s.mgmt.GetConfig)
|
||||||
mgmt.GET("/config.yaml", s.mgmt.GetConfigYAML)
|
mgmt.GET("/config.yaml", s.mgmt.GetConfigYAML)
|
||||||
mgmt.PUT("/config.yaml", s.mgmt.PutConfigYAML)
|
mgmt.PUT("/config.yaml", s.mgmt.PutConfigYAML)
|
||||||
|
mgmt.GET("/latest-version", s.mgmt.GetLatestVersion)
|
||||||
|
|
||||||
mgmt.GET("/debug", s.mgmt.GetDebug)
|
mgmt.GET("/debug", s.mgmt.GetDebug)
|
||||||
mgmt.PUT("/debug", s.mgmt.PutDebug)
|
mgmt.PUT("/debug", s.mgmt.PutDebug)
|
||||||
|
|||||||
@@ -85,6 +85,9 @@ type InlineData struct {
|
|||||||
// FunctionCall represents a tool call requested by the model.
|
// FunctionCall represents a tool call requested by the model.
|
||||||
// It includes the function name and its arguments that the model wants to execute.
|
// It includes the function name and its arguments that the model wants to execute.
|
||||||
type FunctionCall struct {
|
type FunctionCall struct {
|
||||||
|
// ID is the identifier of the function to be called.
|
||||||
|
ID string `json:"id,omitempty"`
|
||||||
|
|
||||||
// Name is the identifier of the function to be called.
|
// Name is the identifier of the function to be called.
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
|
|
||||||
@@ -95,6 +98,9 @@ type FunctionCall struct {
|
|||||||
// FunctionResponse represents the result of a tool execution.
|
// FunctionResponse represents the result of a tool execution.
|
||||||
// This is sent back to the model after a tool call has been processed.
|
// This is sent back to the model after a tool call has been processed.
|
||||||
type FunctionResponse struct {
|
type FunctionResponse struct {
|
||||||
|
// ID is the identifier of the function to be called.
|
||||||
|
ID string `json:"id,omitempty"`
|
||||||
|
|
||||||
// Name is the identifier of the function that was called.
|
// Name is the identifier of the function that was called.
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ import (
|
|||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const skipGinLogKey = "__gin_skip_request_logging__"
|
||||||
|
|
||||||
// GinLogrusLogger returns a Gin middleware handler that logs HTTP requests and responses
|
// GinLogrusLogger returns a Gin middleware handler that logs HTTP requests and responses
|
||||||
// using logrus. It captures request details including method, path, status code, latency,
|
// using logrus. It captures request details including method, path, status code, latency,
|
||||||
// client IP, and any error messages, formatting them in a Gin-style log format.
|
// client IP, and any error messages, formatting them in a Gin-style log format.
|
||||||
@@ -28,6 +30,10 @@ func GinLogrusLogger() gin.HandlerFunc {
|
|||||||
|
|
||||||
c.Next()
|
c.Next()
|
||||||
|
|
||||||
|
if shouldSkipGinRequestLogging(c) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if raw != "" {
|
if raw != "" {
|
||||||
path = path + "?" + raw
|
path = path + "?" + raw
|
||||||
}
|
}
|
||||||
@@ -77,3 +83,24 @@ func GinLogrusRecovery() gin.HandlerFunc {
|
|||||||
c.AbortWithStatus(http.StatusInternalServerError)
|
c.AbortWithStatus(http.StatusInternalServerError)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SkipGinRequestLogging marks the provided Gin context so that GinLogrusLogger
|
||||||
|
// will skip emitting a log line for the associated request.
|
||||||
|
func SkipGinRequestLogging(c *gin.Context) {
|
||||||
|
if c == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Set(skipGinLogKey, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func shouldSkipGinRequestLogging(c *gin.Context) bool {
|
||||||
|
if c == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
val, exists := c.Get(skipGinLogKey)
|
||||||
|
if !exists {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
flag, ok := val.(bool)
|
||||||
|
return ok && flag
|
||||||
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import (
|
|||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
||||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/registry"
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/registry"
|
||||||
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/util"
|
||||||
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
|
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
|
||||||
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor"
|
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor"
|
||||||
sdktranslator "github.com/router-for-me/CLIProxyAPI/v6/sdk/translator"
|
sdktranslator "github.com/router-for-me/CLIProxyAPI/v6/sdk/translator"
|
||||||
@@ -508,8 +509,46 @@ func (e *AntigravityExecutor) buildRequest(ctx context.Context, auth *cliproxyau
|
|||||||
requestURL.WriteString(url.QueryEscape(alt))
|
requestURL.WriteString(url.QueryEscape(alt))
|
||||||
}
|
}
|
||||||
|
|
||||||
payload = geminiToAntigravity(modelName, payload)
|
// Extract project_id from auth metadata if available
|
||||||
|
projectID := ""
|
||||||
|
if auth != nil && auth.Metadata != nil {
|
||||||
|
if pid, ok := auth.Metadata["project_id"].(string); ok {
|
||||||
|
projectID = strings.TrimSpace(pid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
payload = geminiToAntigravity(modelName, payload, projectID)
|
||||||
payload, _ = sjson.SetBytes(payload, "model", alias2ModelName(modelName))
|
payload, _ = sjson.SetBytes(payload, "model", alias2ModelName(modelName))
|
||||||
|
|
||||||
|
if strings.Contains(modelName, "claude") {
|
||||||
|
strJSON := string(payload)
|
||||||
|
paths := make([]string, 0)
|
||||||
|
util.Walk(gjson.ParseBytes(payload), "", "parametersJsonSchema", &paths)
|
||||||
|
for _, p := range paths {
|
||||||
|
strJSON, _ = util.RenameKey(strJSON, p, p[:len(p)-len("parametersJsonSchema")]+"parameters")
|
||||||
|
}
|
||||||
|
|
||||||
|
strJSON = util.DeleteKey(strJSON, "$schema")
|
||||||
|
strJSON = util.DeleteKey(strJSON, "maxItems")
|
||||||
|
strJSON = util.DeleteKey(strJSON, "minItems")
|
||||||
|
strJSON = util.DeleteKey(strJSON, "minLength")
|
||||||
|
strJSON = util.DeleteKey(strJSON, "maxLength")
|
||||||
|
strJSON = util.DeleteKey(strJSON, "exclusiveMinimum")
|
||||||
|
|
||||||
|
paths = make([]string, 0)
|
||||||
|
util.Walk(gjson.Parse(strJSON), "", "anyOf", &paths)
|
||||||
|
for _, p := range paths {
|
||||||
|
anyOf := gjson.Get(strJSON, p)
|
||||||
|
if anyOf.IsArray() {
|
||||||
|
anyOfItems := anyOf.Array()
|
||||||
|
if len(anyOfItems) > 0 {
|
||||||
|
strJSON, _ = sjson.SetRaw(strJSON, p[:len(p)-len(".anyOf")], anyOfItems[0].Raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
payload = []byte(strJSON)
|
||||||
|
}
|
||||||
|
|
||||||
httpReq, errReq := http.NewRequestWithContext(ctx, http.MethodPost, requestURL.String(), bytes.NewReader(payload))
|
httpReq, errReq := http.NewRequestWithContext(ctx, http.MethodPost, requestURL.String(), bytes.NewReader(payload))
|
||||||
if errReq != nil {
|
if errReq != nil {
|
||||||
return nil, errReq
|
return nil, errReq
|
||||||
@@ -670,10 +709,16 @@ func resolveCustomAntigravityBaseURL(auth *cliproxyauth.Auth) string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
func geminiToAntigravity(modelName string, payload []byte) []byte {
|
func geminiToAntigravity(modelName string, payload []byte, projectID string) []byte {
|
||||||
template, _ := sjson.Set(string(payload), "model", modelName)
|
template, _ := sjson.Set(string(payload), "model", modelName)
|
||||||
template, _ = sjson.Set(template, "userAgent", "antigravity")
|
template, _ = sjson.Set(template, "userAgent", "antigravity")
|
||||||
template, _ = sjson.Set(template, "project", generateProjectID())
|
|
||||||
|
// Use real project ID from auth if available, otherwise generate random (legacy fallback)
|
||||||
|
if projectID != "" {
|
||||||
|
template, _ = sjson.Set(template, "project", projectID)
|
||||||
|
} else {
|
||||||
|
template, _ = sjson.Set(template, "project", generateProjectID())
|
||||||
|
}
|
||||||
template, _ = sjson.Set(template, "requestId", generateRequestID())
|
template, _ = sjson.Set(template, "requestId", generateRequestID())
|
||||||
template, _ = sjson.Set(template, "request.sessionId", generateSessionID())
|
template, _ = sjson.Set(template, "request.sessionId", generateSessionID())
|
||||||
|
|
||||||
|
|||||||
@@ -89,10 +89,11 @@ func ConvertClaudeRequestToAntigravity(modelName string, inputRawJSON []byte, _
|
|||||||
} else if contentTypeResult.Type == gjson.String && contentTypeResult.String() == "tool_use" {
|
} else if contentTypeResult.Type == gjson.String && contentTypeResult.String() == "tool_use" {
|
||||||
functionName := contentResult.Get("name").String()
|
functionName := contentResult.Get("name").String()
|
||||||
functionArgs := contentResult.Get("input").String()
|
functionArgs := contentResult.Get("input").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{
|
clientContent.Parts = append(clientContent.Parts, client.Part{
|
||||||
FunctionCall: &client.FunctionCall{Name: functionName, Args: args},
|
FunctionCall: &client.FunctionCall{ID: functionID, Name: functionName, Args: args},
|
||||||
ThoughtSignature: geminiCLIClaudeThoughtSignature,
|
ThoughtSignature: geminiCLIClaudeThoughtSignature,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -105,7 +106,7 @@ func ConvertClaudeRequestToAntigravity(modelName string, inputRawJSON []byte, _
|
|||||||
funcName = strings.Join(toolCallIDs[0:len(toolCallIDs)-1], "-")
|
funcName = strings.Join(toolCallIDs[0:len(toolCallIDs)-1], "-")
|
||||||
}
|
}
|
||||||
responseData := contentResult.Get("content").Raw
|
responseData := contentResult.Get("content").Raw
|
||||||
functionResponse := client.FunctionResponse{Name: funcName, Response: map[string]interface{}{"result": responseData}}
|
functionResponse := client.FunctionResponse{ID: toolCallID, Name: funcName, Response: map[string]interface{}{"result": responseData}}
|
||||||
clientContent.Parts = append(clientContent.Parts, client.Part{FunctionResponse: &functionResponse})
|
clientContent.Parts = append(clientContent.Parts, client.Part{FunctionResponse: &functionResponse})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -141,35 +141,38 @@ func ConvertAntigravityResponseToClaude(_ context.Context, _ string, originalReq
|
|||||||
params.ResponseType = 2 // Set state to thinking
|
params.ResponseType = 2 // Set state to thinking
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Process regular text content (user-visible output)
|
finishReasonResult := gjson.GetBytes(rawJSON, "response.candidates.0.finishReason")
|
||||||
// Continue existing text block if already in content state
|
if partTextResult.String() != "" || !finishReasonResult.Exists() {
|
||||||
if params.ResponseType == 1 {
|
// Process regular text content (user-visible output)
|
||||||
output = output + "event: content_block_delta\n"
|
// Continue existing text block if already in content state
|
||||||
data, _ := sjson.Set(fmt.Sprintf(`{"type":"content_block_delta","index":%d,"delta":{"type":"text_delta","text":""}}`, params.ResponseIndex), "delta.text", partTextResult.String())
|
if params.ResponseType == 1 {
|
||||||
output = output + fmt.Sprintf("data: %s\n\n\n", data)
|
output = output + "event: content_block_delta\n"
|
||||||
} else {
|
data, _ := sjson.Set(fmt.Sprintf(`{"type":"content_block_delta","index":%d,"delta":{"type":"text_delta","text":""}}`, params.ResponseIndex), "delta.text", partTextResult.String())
|
||||||
// Transition from another state to text content
|
output = output + fmt.Sprintf("data: %s\n\n\n", data)
|
||||||
// First, close any existing content block
|
} else {
|
||||||
if params.ResponseType != 0 {
|
// Transition from another state to text content
|
||||||
if params.ResponseType == 2 {
|
// First, close any existing content block
|
||||||
// output = output + "event: content_block_delta\n"
|
if params.ResponseType != 0 {
|
||||||
// output = output + fmt.Sprintf(`data: {"type":"content_block_delta","index":%d,"delta":{"type":"signature_delta","signature":null}}`, params.ResponseIndex)
|
if params.ResponseType == 2 {
|
||||||
// output = output + "\n\n\n"
|
// output = output + "event: content_block_delta\n"
|
||||||
|
// output = output + fmt.Sprintf(`data: {"type":"content_block_delta","index":%d,"delta":{"type":"signature_delta","signature":null}}`, params.ResponseIndex)
|
||||||
|
// output = output + "\n\n\n"
|
||||||
|
}
|
||||||
|
output = output + "event: content_block_stop\n"
|
||||||
|
output = output + fmt.Sprintf(`data: {"type":"content_block_stop","index":%d}`, params.ResponseIndex)
|
||||||
|
output = output + "\n\n\n"
|
||||||
|
params.ResponseIndex++
|
||||||
}
|
}
|
||||||
output = output + "event: content_block_stop\n"
|
|
||||||
output = output + fmt.Sprintf(`data: {"type":"content_block_stop","index":%d}`, params.ResponseIndex)
|
|
||||||
output = output + "\n\n\n"
|
|
||||||
params.ResponseIndex++
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if functionCallResult.Exists() {
|
} else if functionCallResult.Exists() {
|
||||||
|
|||||||
@@ -251,6 +251,7 @@ func ConvertOpenAIRequestToAntigravity(modelName string, inputRawJSON []byte, _
|
|||||||
fid := tc.Get("id").String()
|
fid := tc.Get("id").String()
|
||||||
fname := tc.Get("function.name").String()
|
fname := tc.Get("function.name").String()
|
||||||
fargs := tc.Get("function.arguments").String()
|
fargs := tc.Get("function.arguments").String()
|
||||||
|
node, _ = sjson.SetBytes(node, "parts."+itoa(p)+".functionCall.id", fid)
|
||||||
node, _ = sjson.SetBytes(node, "parts."+itoa(p)+".functionCall.name", fname)
|
node, _ = sjson.SetBytes(node, "parts."+itoa(p)+".functionCall.name", fname)
|
||||||
node, _ = sjson.SetRawBytes(node, "parts."+itoa(p)+".functionCall.args", []byte(fargs))
|
node, _ = sjson.SetRawBytes(node, "parts."+itoa(p)+".functionCall.args", []byte(fargs))
|
||||||
node, _ = sjson.SetBytes(node, "parts."+itoa(p)+".thoughtSignature", geminiCLIFunctionThoughtSignature)
|
node, _ = sjson.SetBytes(node, "parts."+itoa(p)+".thoughtSignature", geminiCLIFunctionThoughtSignature)
|
||||||
@@ -266,6 +267,7 @@ func ConvertOpenAIRequestToAntigravity(modelName string, inputRawJSON []byte, _
|
|||||||
pp := 0
|
pp := 0
|
||||||
for _, fid := range fIDs {
|
for _, fid := range fIDs {
|
||||||
if name, ok := tcID2Name[fid]; ok {
|
if name, ok := tcID2Name[fid]; ok {
|
||||||
|
toolNode, _ = sjson.SetBytes(toolNode, "parts."+itoa(pp)+".functionResponse.id", fid)
|
||||||
toolNode, _ = sjson.SetBytes(toolNode, "parts."+itoa(pp)+".functionResponse.name", name)
|
toolNode, _ = sjson.SetBytes(toolNode, "parts."+itoa(pp)+".functionResponse.name", name)
|
||||||
resp := toolResponses[fid]
|
resp := toolResponses[fid]
|
||||||
if resp == "" {
|
if resp == "" {
|
||||||
|
|||||||
@@ -327,7 +327,7 @@ func buildReverseMapFromGeminiOriginal(original []byte) map[string]string {
|
|||||||
func mustMarshalJSON(v interface{}) string {
|
func mustMarshalJSON(v interface{}) string {
|
||||||
data, err := json.Marshal(v)
|
data, err := json.Marshal(v)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
panic(err)
|
return ""
|
||||||
}
|
}
|
||||||
return string(data)
|
return string(data)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -249,6 +249,7 @@ func ConvertOpenAIResponsesRequestToGemini(modelName string, inputRawJSON []byte
|
|||||||
functionCall := `{"functionCall":{"name":"","args":{}}}`
|
functionCall := `{"functionCall":{"name":"","args":{}}}`
|
||||||
functionCall, _ = sjson.Set(functionCall, "functionCall.name", name)
|
functionCall, _ = sjson.Set(functionCall, "functionCall.name", name)
|
||||||
functionCall, _ = sjson.Set(functionCall, "thoughtSignature", geminiResponsesThoughtSignature)
|
functionCall, _ = sjson.Set(functionCall, "thoughtSignature", geminiResponsesThoughtSignature)
|
||||||
|
functionCall, _ = sjson.Set(functionCall, "functionCall.id", item.Get("call_id").String())
|
||||||
|
|
||||||
// Parse arguments JSON string and set as args object
|
// Parse arguments JSON string and set as args object
|
||||||
if arguments != "" {
|
if arguments != "" {
|
||||||
@@ -285,6 +286,7 @@ func ConvertOpenAIResponsesRequestToGemini(modelName string, inputRawJSON []byte
|
|||||||
}
|
}
|
||||||
|
|
||||||
functionResponse, _ = sjson.Set(functionResponse, "functionResponse.name", functionName)
|
functionResponse, _ = sjson.Set(functionResponse, "functionResponse.name", functionName)
|
||||||
|
functionResponse, _ = sjson.Set(functionResponse, "functionResponse.id", callID)
|
||||||
|
|
||||||
// Set the raw JSON output directly (preserves string encoding)
|
// Set the raw JSON output directly (preserves string encoding)
|
||||||
if outputRaw != "" && outputRaw != "null" {
|
if outputRaw != "" && outputRaw != "null" {
|
||||||
|
|||||||
@@ -79,6 +79,15 @@ func RenameKey(jsonStr, oldKeyPath, newKeyPath string) (string, error) {
|
|||||||
return finalJson, nil
|
return finalJson, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func DeleteKey(jsonStr, keyName string) string {
|
||||||
|
paths := make([]string, 0)
|
||||||
|
Walk(gjson.Parse(jsonStr), "", keyName, &paths)
|
||||||
|
for _, p := range paths {
|
||||||
|
jsonStr, _ = sjson.Delete(jsonStr, p)
|
||||||
|
}
|
||||||
|
return jsonStr
|
||||||
|
}
|
||||||
|
|
||||||
// FixJSON converts non-standard JSON that uses single quotes for strings into
|
// FixJSON converts non-standard JSON that uses single quotes for strings into
|
||||||
// RFC 8259-compliant JSON by converting those single-quoted strings to
|
// RFC 8259-compliant JSON by converting those single-quoted strings to
|
||||||
// double-quoted strings with proper escaping.
|
// double-quoted strings with proper escaping.
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
@@ -127,6 +128,18 @@ func (AntigravityAuthenticator) Login(ctx context.Context, cfg *config.Config, o
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fetch project ID via loadCodeAssist (same approach as Gemini CLI)
|
||||||
|
projectID := ""
|
||||||
|
if tokenResp.AccessToken != "" {
|
||||||
|
fetchedProjectID, errProject := fetchAntigravityProjectID(ctx, tokenResp.AccessToken, httpClient)
|
||||||
|
if errProject != nil {
|
||||||
|
log.Warnf("antigravity: failed to fetch project ID: %v", errProject)
|
||||||
|
} else {
|
||||||
|
projectID = fetchedProjectID
|
||||||
|
log.Infof("antigravity: obtained project ID %s", projectID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
metadata := map[string]any{
|
metadata := map[string]any{
|
||||||
"type": "antigravity",
|
"type": "antigravity",
|
||||||
@@ -139,6 +152,9 @@ func (AntigravityAuthenticator) Login(ctx context.Context, cfg *config.Config, o
|
|||||||
if email != "" {
|
if email != "" {
|
||||||
metadata["email"] = email
|
metadata["email"] = email
|
||||||
}
|
}
|
||||||
|
if projectID != "" {
|
||||||
|
metadata["project_id"] = projectID
|
||||||
|
}
|
||||||
|
|
||||||
fileName := sanitizeAntigravityFileName(email)
|
fileName := sanitizeAntigravityFileName(email)
|
||||||
label := email
|
label := email
|
||||||
@@ -147,6 +163,9 @@ func (AntigravityAuthenticator) Login(ctx context.Context, cfg *config.Config, o
|
|||||||
}
|
}
|
||||||
|
|
||||||
fmt.Println("Antigravity authentication successful")
|
fmt.Println("Antigravity authentication successful")
|
||||||
|
if projectID != "" {
|
||||||
|
fmt.Printf("Using GCP project: %s\n", projectID)
|
||||||
|
}
|
||||||
return &coreauth.Auth{
|
return &coreauth.Auth{
|
||||||
ID: fileName,
|
ID: fileName,
|
||||||
Provider: "antigravity",
|
Provider: "antigravity",
|
||||||
@@ -291,3 +310,84 @@ func sanitizeAntigravityFileName(email string) string {
|
|||||||
replacer := strings.NewReplacer("@", "_", ".", "_")
|
replacer := strings.NewReplacer("@", "_", ".", "_")
|
||||||
return fmt.Sprintf("antigravity-%s.json", replacer.Replace(email))
|
return fmt.Sprintf("antigravity-%s.json", replacer.Replace(email))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Antigravity API constants for project discovery
|
||||||
|
const (
|
||||||
|
antigravityAPIEndpoint = "https://cloudcode-pa.googleapis.com"
|
||||||
|
antigravityAPIVersion = "v1internal"
|
||||||
|
antigravityAPIUserAgent = "google-api-nodejs-client/9.15.1"
|
||||||
|
antigravityAPIClient = "google-cloud-sdk vscode_cloudshelleditor/0.1"
|
||||||
|
antigravityClientMetadata = `{"ideType":"IDE_UNSPECIFIED","platform":"PLATFORM_UNSPECIFIED","pluginType":"GEMINI"}`
|
||||||
|
)
|
||||||
|
|
||||||
|
// fetchAntigravityProjectID retrieves the project ID for the authenticated user via loadCodeAssist.
|
||||||
|
// This uses the same approach as Gemini CLI to get the cloudaicompanionProject.
|
||||||
|
func fetchAntigravityProjectID(ctx context.Context, accessToken string, httpClient *http.Client) (string, error) {
|
||||||
|
// Call loadCodeAssist to get the project
|
||||||
|
loadReqBody := map[string]any{
|
||||||
|
"metadata": map[string]string{
|
||||||
|
"ideType": "IDE_UNSPECIFIED",
|
||||||
|
"platform": "PLATFORM_UNSPECIFIED",
|
||||||
|
"pluginType": "GEMINI",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
rawBody, errMarshal := json.Marshal(loadReqBody)
|
||||||
|
if errMarshal != nil {
|
||||||
|
return "", fmt.Errorf("marshal request body: %w", errMarshal)
|
||||||
|
}
|
||||||
|
|
||||||
|
endpointURL := fmt.Sprintf("%s/%s:loadCodeAssist", antigravityAPIEndpoint, antigravityAPIVersion)
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpointURL, strings.NewReader(string(rawBody)))
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("create request: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Authorization", "Bearer "+accessToken)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("User-Agent", antigravityAPIUserAgent)
|
||||||
|
req.Header.Set("X-Goog-Api-Client", antigravityAPIClient)
|
||||||
|
req.Header.Set("Client-Metadata", antigravityClientMetadata)
|
||||||
|
|
||||||
|
resp, errDo := httpClient.Do(req)
|
||||||
|
if errDo != nil {
|
||||||
|
return "", fmt.Errorf("execute request: %w", errDo)
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
if errClose := resp.Body.Close(); errClose != nil {
|
||||||
|
log.Errorf("antigravity loadCodeAssist: close body error: %v", errClose)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
bodyBytes, errRead := io.ReadAll(resp.Body)
|
||||||
|
if errRead != nil {
|
||||||
|
return "", fmt.Errorf("read response: %w", errRead)
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
|
||||||
|
return "", fmt.Errorf("request failed with status %d: %s", resp.StatusCode, strings.TrimSpace(string(bodyBytes)))
|
||||||
|
}
|
||||||
|
|
||||||
|
var loadResp map[string]any
|
||||||
|
if errDecode := json.Unmarshal(bodyBytes, &loadResp); errDecode != nil {
|
||||||
|
return "", fmt.Errorf("decode response: %w", errDecode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract projectID from response
|
||||||
|
projectID := ""
|
||||||
|
if id, ok := loadResp["cloudaicompanionProject"].(string); ok {
|
||||||
|
projectID = strings.TrimSpace(id)
|
||||||
|
}
|
||||||
|
if projectID == "" {
|
||||||
|
if projectMap, ok := loadResp["cloudaicompanionProject"].(map[string]any); ok {
|
||||||
|
if id, okID := projectMap["id"].(string); okID {
|
||||||
|
projectID = strings.TrimSpace(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if projectID == "" {
|
||||||
|
return "", fmt.Errorf("no cloudaicompanionProject in response")
|
||||||
|
}
|
||||||
|
|
||||||
|
return projectID, nil
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user